c5500bcc97
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):
- **Cart**: CartItem gained an optional `variant?: string` field — every
function that used to match a line by `id` alone (addToCart/
removeFromCart/setQuantity) now matches by `(id, variant)` together via
a shared sameLine() helper, so two lines for the same product with
different variants stay separate entries. `variant` undefined on both
sides (the no-variants case) still matches by simple equality, so every
pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
button when given a non-empty `variants` prop (ProductGrid/
RelatedProducts pass product.variants straight through); defaults to
the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
variant's priceOverride wins over the base product price. Every cart/
checkout/order-confirmation total and per-line price display now goes
through this instead of reading product.price directly (fixes both a
wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
pattern would have had the moment two variants of one product were both
in the cart).
- **Checkout**: re-validates the requested variant server-side (same
"never trust the client" reasoning as price re-derivation) — a variant
name that doesn't exist on that product fails the whole checkout.
variantName snapshots onto orders.items, shown as a parenthetical next
to the product name on the confirmation email, both invoice PDF types,
and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
/api/account/cart) carries the selection through a login/logout cycle,
not just the current session.
Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
55 lines
2.5 KiB
TypeScript
55 lines
2.5 KiB
TypeScript
import { discountPercent } from "./format";
|
|
import type { Product } from "./payload";
|
|
|
|
// Previously duplicated independently in CartContent.tsx, CheckoutContent.tsx,
|
|
// and BestellbestaetigungContent.tsx — pulled into one place now that adding
|
|
// discount-code math to all three at once would otherwise mean hand-editing
|
|
// 3 near-identical blocks (and risking them drifting apart).
|
|
|
|
export type CartLine = { entry: { qty: number; variant?: string }; product: Product };
|
|
export type DiscountLike = { type: "percent" | "fixed"; value: number };
|
|
|
|
// A selected variant's priceOverride wins over the base product price —
|
|
// null/undefined priceOverride (or no variant selected at all) falls back
|
|
// to it. The one place cart/checkout math needs to know about variants at
|
|
// all; every other total below builds on this instead of `product.price`
|
|
// directly.
|
|
export function effectivePrice(entry: { variant?: string }, product: Product): number {
|
|
if (!entry.variant) return product.price;
|
|
const variant = product.variants.find((v) => v.name === entry.variant);
|
|
return variant?.priceOverride ?? product.price;
|
|
}
|
|
|
|
// Split out from computeCartTotals() below because callers need a subtotal
|
|
// figure *before* they can decide a shipping cost (e.g. checking it against
|
|
// a free-shipping threshold) — which computeCartTotals itself takes as an
|
|
// input, not something it can decide on its own.
|
|
export function computeSubtotal(items: CartLine[]): number {
|
|
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
|
|
}
|
|
|
|
export type CartTotals = {
|
|
subtotal: number;
|
|
/** compareAtPrice-based per-product savings — already excluded from
|
|
* `subtotal` (which uses `price`, not `compareAtPrice`), this is a
|
|
* separate display line only. */
|
|
totalSavings: number;
|
|
discountAmount: number;
|
|
total: number;
|
|
};
|
|
|
|
export function computeCartTotals(items: CartLine[], shippingCost: number, discount?: DiscountLike | null): CartTotals {
|
|
const subtotal = computeSubtotal(items);
|
|
const totalSavings = items.reduce((sum, { entry, product }) => {
|
|
const productDiscountPct = discountPercent(product.price, product.compareAtPrice);
|
|
return productDiscountPct !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
|
}, 0);
|
|
const discountAmount = !discount
|
|
? 0
|
|
: discount.type === "percent"
|
|
? (subtotal * discount.value) / 100
|
|
: Math.min(discount.value, subtotal);
|
|
const total = Math.max(0, subtotal - discountAmount) + shippingCost;
|
|
return { subtotal, totalSavings, discountAmount, total };
|
|
}
|