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
@@ -5,6 +5,7 @@ import { Reveal } from "../../../components/Reveal";
import { Footer } from "../../../components/Footer";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
@@ -54,6 +55,22 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
</div>
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
{(() => {
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
return trackingUrl ? (
<a href={trackingUrl} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
{order.trackingNumber}
</a>
) : (
<p className="text-body-sm text-text-primary">{order.trackingNumber}</p>
);
})()}
</div>
)}
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
<p className="text-body-sm text-text-primary">
@@ -71,6 +88,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<div className="flex-1 flex flex-col gap-0.5">
<p className="text-body-sm text-text-primary">
{item.quantity} × {item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</p>
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
{item.returnQuantity > 0 && (