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:
@@ -270,6 +270,67 @@ check against Payload's public API, unlike most content on this site.
|
||||
labelled "zahlungspflichtig" but nothing actually captures a payment
|
||||
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
||||
|
||||
### Product variants
|
||||
|
||||
A cart line's identity is `(id, variant)` together, not `id` alone —
|
||||
`app/lib/cart.ts`'s `CartItem` gained an optional `variant?: string` field
|
||||
(the selected `products.variants[].name`), and every function that used to
|
||||
match a line by `id` (`addToCart`/`removeFromCart`/`setQuantity`) now
|
||||
matches by both via a shared `sameLine()` helper, so two lines for the
|
||||
same product with different variants stay genuinely separate entries
|
||||
instead of merging or clobbering each other. `variant` undefined on both
|
||||
sides (the common no-variants case) still matches by simple equality —
|
||||
every pre-existing call site that never passes a variant keeps working
|
||||
unchanged.
|
||||
|
||||
**Where a variant gets picked**: `AddToCartInlineButton` renders a
|
||||
`<select>` above the button when its `variants` prop is non-empty
|
||||
(`ProductGrid.tsx`/`RelatedProducts.tsx` pass `product.variants` straight
|
||||
through from `getProducts()`'s mapped `Product` type), defaulting to the
|
||||
first variant. `AddToCartButton` (the marketing-page-specific one on
|
||||
`/todo-cards` and the homepage spotlight) does **not** have a variant
|
||||
picker — those reference one hardcoded product id directly with no
|
||||
product data in scope, so if that specific product ever gets variants,
|
||||
this button would need its own follow-up work.
|
||||
|
||||
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
|
||||
a selected variant's `priceOverride` wins over the base `product.price`
|
||||
(falling back to it when unset or no variant selected). Every cart/
|
||||
checkout/order-confirmation total (`computeSubtotal`, `computeCartTotals`,
|
||||
and each page's own per-line price display in `CartContent.tsx`,
|
||||
`CheckoutContent.tsx`, `BestellbestaetigungContent.tsx`) goes through this
|
||||
instead of reading `product.price` directly. The checkout route
|
||||
(`/api/checkout/route.ts`) re-validates the requested variant server-side
|
||||
too — same "never trust the client" reasoning as price re-derivation
|
||||
generally: a `line.variant` naming something that doesn't exist on that
|
||||
product (removed, or a tampered request) fails the whole checkout rather
|
||||
than silently falling back to the base price.
|
||||
|
||||
**Snapshotting**: `orders.items[].variantName` captures which variant was
|
||||
picked at order time (same "snapshot, not a live relationship" reasoning
|
||||
as `bundleContents`) — shown as a parenthetical next to the product name
|
||||
on the order-confirmation email, both invoice PDF types, and the
|
||||
order-detail page. The server-side cart mirror
|
||||
(`Customers.cart[].variantName`, synced via `/api/account/cart`) carries
|
||||
the same field so a variant selection survives a login/logout cycle, not
|
||||
just the current session.
|
||||
|
||||
See the Payload README's "Inventory & product variants" section for the
|
||||
backend data model (`products.variants`, stock bookkeeping) this all
|
||||
builds on.
|
||||
|
||||
### Tracking numbers
|
||||
|
||||
`orders.carrier`/`trackingNumber` (admin-entered in Payload, no carrier
|
||||
API) show as a clickable link on `/konto/bestellungen/[orderNumber]` when
|
||||
set — `app/lib/tracking.ts`'s `buildTrackingUrl(carrier, trackingNumber)`
|
||||
mirrors the Payload backend's own copy of this file byte-for-byte close
|
||||
(same carrier set/URL patterns, kept in sync by hand, no shared package
|
||||
between the two deployments) so the link an admin sees generated in the
|
||||
`order-shipped` email matches exactly what a customer sees here. Falls
|
||||
back to plain (non-linked) text for `carrier: 'other'`, which has no known
|
||||
URL pattern.
|
||||
|
||||
### Product bundles & per-product tax rates
|
||||
|
||||
Both resolved server-side in `/api/checkout/route.ts`, at the same point
|
||||
|
||||
Reference in New Issue
Block a user