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
+18 -9
View File
@@ -7,7 +7,7 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
@@ -149,8 +149,15 @@ export function CartContent({
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
{items.map(({ entry, product }, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const unitPrice = effectivePrice(entry, product);
// (id, variant) together, not id alone — two lines for the
// same product with different variants need distinct React
// keys/element ids and must each only affect their own line
// when the quantity or remove control is used, same "full
// key" reasoning as cart.ts's own sameLine().
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
return (
<div key={product.id} className="w-full">
<div key={lineKey} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
@@ -167,6 +174,7 @@ export function CartContent({
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
<div className="flex flex-col gap-0.5 items-start">
@@ -175,19 +183,20 @@ export function CartContent({
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
</p>
</div>
</div>
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
<label className="sr-only" htmlFor={`qty-${product.id}`}>
<label className="sr-only" htmlFor={`qty-${lineKey}`}>
Menge für {product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</label>
<select
id={`qty-${product.id}`}
id={`qty-${lineKey}`}
value={entry.qty}
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
>
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
@@ -195,12 +204,12 @@ export function CartContent({
))}
</select>
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
{formatPrice(entry.qty * product.price)}
{formatPrice(entry.qty * unitPrice)}
</p>
<button
type="button"
onClick={() => removeFromCart(product.id)}
aria-label={`${product.name} entfernen`}
onClick={() => removeFromCart(product.id, entry.variant)}
aria-label={`${product.name}${entry.variant ? ` (${entry.variant})` : ""} entfernen`}
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
>
×
+1 -1
View File
@@ -164,7 +164,7 @@ export function RelatedProducts() {
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} />
<AddToCartInlineButton id={product.id} variants={product.variants} />
</div>
</div>
))}