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
+61
View File
@@ -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
+5 -6
View File
@@ -22,12 +22,11 @@ export async function POST(request: Request) {
const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
const productsBySlug = await fetchProductsBySlug();
const lines = cart
.map((item) => {
const product = productsBySlug.get(item.id);
return product ? { productId: product.id, productSlug: product.slug, quantity: item.qty } : null;
})
.filter((line): line is { productId: number; productSlug: string; quantity: number } => line !== null);
const lines: { productId: number; productSlug: string; quantity: number; variant?: string }[] = [];
for (const item of cart) {
const product = productsBySlug.get(item.id);
if (product) lines.push({ productId: product.id, productSlug: product.slug, quantity: item.qty, variant: item.variant });
}
const ok = await saveServerCart(session.token, session.customer.id, lines);
return NextResponse.json({ ok });
+13 -1
View File
@@ -98,19 +98,30 @@ export async function POST(request: Request) {
imageUrl: string | null;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
// Same "never trust the client" reasoning as unitPrice below — a
// requested variant that no longer exists on this product (removed,
// or never existed — a tampered request) fails the whole checkout
// rather than silently falling back to the base product/price.
let variant: { name: string; priceOverride: number | null } | null = null;
if (line.variant) {
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
}
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
items.push({
productId: product.id,
productName: product.name,
quantity: line.qty,
unitPrice: product.price,
unitPrice: variant?.priceOverride ?? product.price,
imageUrl,
taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
});
}
const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
@@ -202,6 +213,7 @@ export async function POST(request: Request) {
imageUrl: i.imageUrl,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
})),
subtotal,
shippingCost,
@@ -5,7 +5,7 @@ import Link from "next/link";
import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { computeCartTotals } from "../../lib/cartTotals";
import { computeCartTotals, effectivePrice } from "../../lib/cartTotals";
import { formatPrice, formatDate } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
@@ -178,22 +178,29 @@ export function BestellbestaetigungContent() {
Bestellübersicht
</p>
{items.map(({ entry, product }) => (
<div key={product.id} className="flex gap-4 items-center w-full">
{items.map(({ entry, product }) => {
const unitPrice = effectivePrice(entry, product);
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
return (
<div key={lineKey} className="flex gap-4 items-center w-full">
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<p className="text-body-sm text-text-primary">{product.name}</p>
<p className="text-body-sm text-text-primary">
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(product.price)} <span>inkl. MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. MwSt.</span>
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">
{formatPrice(entry.qty * product.price)}
{formatPrice(entry.qty * unitPrice)}
</p>
</div>
))}
);
})}
<div className="h-px bg-border w-full" />
+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>
))}
+14 -7
View File
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice } from "../../lib/cartTotals";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
@@ -581,20 +581,27 @@ export function CheckoutContent({
Deine Bestellung
</p>
{items.map(({ entry, product }) => (
<div key={product.id} className="flex gap-4 items-center w-full">
{items.map(({ entry, product }) => {
const unitPrice = effectivePrice(entry, product);
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
return (
<div key={lineKey} className="flex gap-4 items-center w-full">
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<p className="text-body-sm text-text-primary">{product.name}</p>
<p className="text-body-sm text-text-primary">
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(product.price)} <span>inkl. MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. MwSt.</span>
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * product.price)}</p>
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * unitPrice)}</p>
</div>
))}
);
})}
<div className="h-px bg-border w-full" />
+35 -12
View File
@@ -20,12 +20,19 @@ export function AddToCartInlineButton({
id,
label = "In den Warenkorb",
className,
variants = [],
}: {
id: string;
label?: string;
className?: string;
/** Optional products.variants (name + optional priceOverride). When
* non-empty, a variant must be picked (defaults to the first one) before
* "add to cart" is enabled the selected variant's name is snapshotted
* onto the cart line and, later, the order itself. */
variants?: { name: string; priceOverride: number | null }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
@@ -33,7 +40,7 @@ export function AddToCartInlineButton({
useEffect(() => () => clearTimeout(timeoutRef.current), []);
function handleClick() {
addToCart(id);
addToCart(id, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
@@ -53,16 +60,32 @@ export function AddToCartInlineButton({
: "border-border hover:border-brand";
return (
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
<span
className={
"text-body-sm transition-colors " +
(added ? "font-semibold text-success" : "text-text-primary")
}
>
{added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
<div className="flex flex-col gap-2 w-full">
{variants.length > 0 && (
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
aria-label="Variante auswählen"
>
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
</option>
))}
</select>
)}
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
<span
className={
"text-body-sm transition-colors " +
(added ? "font-semibold text-success" : "text-text-primary")
}
>
{added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
</div>
);
}
@@ -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 && (
+1
View File
@@ -11,6 +11,7 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
image: null,
taxRatePercent: null,
bundleItems: null,
variants: null,
...overrides,
});
+1
View File
@@ -17,6 +17,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
variants: [],
...overrides,
});
+25 -10
View File
@@ -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.
+13 -2
View File
@@ -6,15 +6,26 @@ import type { Product } from "./payload";
// discount-code math to all three at once would otherwise mean hand-editing
// 3 near-identical blocks (and risking them drifting apart).
export type CartLine = { entry: { qty: number }; product: Product };
export type CartLine = { entry: { qty: number; variant?: string }; product: Product };
export type DiscountLike = { type: "percent" | "fixed"; value: number };
// A selected variant's priceOverride wins over the base product price —
// null/undefined priceOverride (or no variant selected at all) falls back
// to it. The one place cart/checkout math needs to know about variants at
// all; every other total below builds on this instead of `product.price`
// directly.
export function effectivePrice(entry: { variant?: string }, product: Product): number {
if (!entry.variant) return product.price;
const variant = product.variants.find((v) => v.name === entry.variant);
return variant?.priceOverride ?? product.price;
}
// Split out from computeCartTotals() below because callers need a subtotal
// figure *before* they can decide a shipping cost (e.g. checking it against
// a free-shipping threshold) — which computeCartTotals itself takes as an
// input, not something it can decide on its own.
export function computeSubtotal(items: CartLine[]): number {
return items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
}
export type CartTotals = {
+5 -1
View File
@@ -86,6 +86,7 @@ export type CorrectionInvoiceItem = {
unitPrice: number;
taxRatePercent: number;
bundleContents?: string | null;
variantName?: string | null;
returnQuantity?: number;
};
@@ -237,7 +238,10 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
{lines.map(({ item, effectiveQuantity }, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colName}>
<Text>{item.productName}</Text>
<Text>
{item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{effectiveQuantity}</Text>
+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;
};
+2 -1
View File
@@ -163,6 +163,7 @@ export type OrderConfirmationItem = {
unitPrice: number;
imageUrl?: string | null;
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
};
export type OrderConfirmationData = {
@@ -208,7 +209,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
}
</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
+5 -2
View File
@@ -85,7 +85,7 @@ function formatPrice(amount: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
export type InvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null };
export type InvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null; variantName?: string | null };
export type InvoiceOrder = {
orderNumber: string;
@@ -266,7 +266,10 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
{order.items.map((item, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colName}>
<Text>{item.productName}</Text>
<Text>
{item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{item.quantity}</Text>
+1
View File
@@ -72,6 +72,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
unitPrice: i.unitPrice,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents ?? null,
variantName: i.variantName ?? null,
})),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
+2
View File
@@ -26,6 +26,7 @@ export type OrderItemInput = {
unitPrice: number;
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
};
export type CreateOrderInput = {
@@ -87,6 +88,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
unitPrice: i.unitPrice,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
+3
View File
@@ -170,6 +170,7 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
variants: { name: string; priceOverride: number | null }[];
};
type PayloadProduct = {
@@ -188,6 +189,7 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
variants: { name: string; priceOverride: number | null }[] | null;
};
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
@@ -211,6 +213,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
variants: product.variants ?? [],
};
}
+7
View File
@@ -7,6 +7,12 @@
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type RawProductVariant = {
name: string;
sku: string | null;
priceOverride: number | null;
};
export type RawProduct = {
id: number;
slug: string;
@@ -16,6 +22,7 @@ export type RawProduct = {
image: { url: string } | number | null;
taxRatePercent: number | null;
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
variants: RawProductVariant[] | null;
};
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
+26
View File
@@ -0,0 +1,26 @@
// Mirrors the Payload backend's own src/lib/tracking.ts — same carrier
// set/labels/URL patterns, kept in sync by hand (two separate
// deployments, no shared package). Used to render a clickable tracking
// link on /konto/bestellungen/[orderNumber]; the backend's copy builds
// the same link for the order-shipped email.
export const CARRIER_LABELS: Record<string, string> = {
dhl: "DHL",
dpd: "DPD",
hermes: "Hermes",
ups: "UPS",
gls: "GLS",
other: "Sonstiger Versanddienstleister",
};
const CARRIER_TRACKING_URL: Record<string, (trackingNumber: string) => string> = {
dhl: (n) => `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(n)}`,
dpd: (n) => `https://tracking.dpd.de/status/de_DE/parcel/${encodeURIComponent(n)}`,
hermes: (n) => `https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#${encodeURIComponent(n)}`,
ups: (n) => `https://www.ups.com/track?loc=de_DE&tracknum=${encodeURIComponent(n)}`,
gls: (n) => `https://www.gls-pakete.de/sendungsverfolgung?trackingNumber=${encodeURIComponent(n)}`,
};
export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null {
if (!carrier || !trackingNumber) return null;
return CARRIER_TRACKING_URL[carrier]?.(trackingNumber) ?? null;
}
+1 -1
View File
@@ -82,7 +82,7 @@ export async function ProductGrid() {
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} />
<AddToCartInlineButton id={product.id} variants={product.variants} />
</div>
</RevealItem>
);