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:
Marco
2026-07-22 17:43:47 +00:00
parent a935357e70
commit c5500bcc97
23 changed files with 280 additions and 64 deletions
+9 -4
View File
@@ -217,7 +217,7 @@ type PayloadCustomerMe = {
zip: string | null;
city: string | null;
country: string | null;
cart: { product: number; productSlug: string; quantity: number }[] | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
export async function getCustomerProfile(token: string): Promise<CustomerProfile | null> {
@@ -353,19 +353,21 @@ export async function getServerCart(token: string): Promise<CartItem[]> {
});
if (!res.ok) return [];
const data: { user: PayloadCustomerMe | null } = await res.json();
return (data.user?.cart ?? []).map((line) => ({ id: line.productSlug, qty: line.quantity }));
return (data.user?.cart ?? []).map((line) =>
line.variantName ? { id: line.productSlug, qty: line.quantity, variant: line.variantName } : { id: line.productSlug, qty: line.quantity },
);
}
export async function saveServerCart(
token: string,
customerId: number,
cart: { productId: number; productSlug: string; quantity: number }[],
cart: { productId: number; productSlug: string; quantity: number; variant?: string }[],
): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity })),
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity, variantName: line.variant ?? null })),
}),
});
return res.ok;
@@ -428,6 +430,8 @@ export type CustomerOrderDetail = CustomerOrder & {
invoiceIssuedAt: string | null;
correctionInvoiceNumber: string | null;
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
@@ -455,6 +459,7 @@ export type CustomerOrderItem = {
unitPrice: number;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
returnQuantity: number;
};