Wire up product variants end-to-end, add tracking-number display
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>
This commit is contained in:
+25
-10
@@ -6,7 +6,13 @@ const CART_KEY = "ep_cart";
|
||||
const CART_EVENT = "ep-cart-updated";
|
||||
const EMPTY_CART: CartItem[] = [];
|
||||
|
||||
export type CartItem = { id: string; qty: number };
|
||||
// `variant` is the selected variant's name (products.variants[].name),
|
||||
// undefined for a plain product with no variants. Two lines with the same
|
||||
// `id` but different `variant` are separate cart entries, never merged —
|
||||
// same "distinguish by the full key, not just id" reasoning as the
|
||||
// server-side cart mirror (Customers.ts's cart array, which stores this
|
||||
// same field as `variantName`).
|
||||
export type CartItem = { id: string; qty: number; variant?: string };
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
@@ -27,16 +33,25 @@ export function getCartCount(): number {
|
||||
return readCart().reduce((sum, item) => sum + item.qty, 0);
|
||||
}
|
||||
|
||||
export function addToCart(id: string, qty = 1) {
|
||||
// A line is identified by (id, variant) together, not id alone — the same
|
||||
// product with two different variants selected are separate cart entries.
|
||||
// `variant` undefined on both sides (the common, no-variants case) still
|
||||
// matches by simple equality, so every existing call site that never
|
||||
// passes a variant keeps working unchanged.
|
||||
function sameLine(item: CartItem, id: string, variant: string | undefined): boolean {
|
||||
return item.id === id && item.variant === variant;
|
||||
}
|
||||
|
||||
export function addToCart(id: string, qty = 1, variant?: string) {
|
||||
const items = readCart();
|
||||
const existing = items.find((i) => i.id === id);
|
||||
const existing = items.find((i) => sameLine(i, id, variant));
|
||||
if (existing) existing.qty += qty;
|
||||
else items.push({ id, qty });
|
||||
else items.push(variant ? { id, qty, variant } : { id, qty });
|
||||
writeCart(items);
|
||||
}
|
||||
|
||||
export function removeFromCart(id: string) {
|
||||
writeCart(readCart().filter((i) => i.id !== id));
|
||||
export function removeFromCart(id: string, variant?: string) {
|
||||
writeCart(readCart().filter((i) => !sameLine(i, id, variant)));
|
||||
}
|
||||
|
||||
// Called by /bestellbestaetigung once it has captured a snapshot of the
|
||||
@@ -50,13 +65,13 @@ export function clearCart() {
|
||||
// qty <= 0 removes the item outright — the cart page's quantity stepper
|
||||
// never lets the visible count go below 1, but this keeps the function
|
||||
// itself safe to call with any integer without a separate remove path.
|
||||
export function setQuantity(id: string, qty: number) {
|
||||
export function setQuantity(id: string, qty: number, variant?: string) {
|
||||
if (qty <= 0) {
|
||||
removeFromCart(id);
|
||||
removeFromCart(id, variant);
|
||||
return;
|
||||
}
|
||||
const items = readCart();
|
||||
const existing = items.find((i) => i.id === id);
|
||||
const existing = items.find((i) => sameLine(i, id, variant));
|
||||
if (existing) existing.qty = qty;
|
||||
writeCart(items);
|
||||
}
|
||||
@@ -118,7 +133,7 @@ export async function mergeServerCartIntoLocal(): Promise<void> {
|
||||
const res = await fetch("/api/account/cart");
|
||||
if (!res.ok) return;
|
||||
const data: { cart?: CartItem[] } = await res.json();
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty);
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
|
||||
} catch {
|
||||
// Best-effort — a failed merge just means the server-side cart stays
|
||||
// as it was; nothing local is lost either way.
|
||||
|
||||
Reference in New Issue
Block a user