From c5500bcc977d9fd648ab7f715e570a497375f43e Mon Sep 17 00:00:00 2001
From: Marco
Date: Wed, 22 Jul 2026 17:43:47 +0000
Subject: [PATCH] Wire up product variants end-to-end, add tracking-number
display
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 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
---
README.md | 61 +++++++++++++++++++
app/api/account/cart/route.ts | 11 ++--
app/api/checkout/route.ts | 14 ++++-
.../components/BestellbestaetigungContent.tsx | 21 ++++---
app/cart/components/CartContent.tsx | 27 +++++---
app/cart/components/RelatedProducts.tsx | 2 +-
app/checkout/components/CheckoutContent.tsx | 21 ++++---
app/components/AddToCartInlineButton.tsx | 47 ++++++++++----
app/konto/bestellungen/[orderNumber]/page.tsx | 18 ++++++
app/lib/__tests__/bundleContents.test.ts | 1 +
app/lib/__tests__/cartTotals.test.ts | 1 +
app/lib/cart.ts | 35 ++++++++---
app/lib/cartTotals.ts | 15 ++++-
app/lib/correctionInvoicePdf.tsx | 6 +-
app/lib/customerAuth.ts | 13 ++--
app/lib/emailTemplates.ts | 3 +-
app/lib/invoicePdf.tsx | 7 ++-
app/lib/orderEmail.ts | 1 +
app/lib/orderServer.ts | 2 +
app/lib/payload.ts | 3 +
app/lib/productsServer.ts | 7 +++
app/lib/tracking.ts | 26 ++++++++
app/shop/components/ProductGrid.tsx | 2 +-
23 files changed, 280 insertions(+), 64 deletions(-)
create mode 100644 app/lib/tracking.ts
diff --git a/README.md b/README.md
index 7308620..6ab3ee1 100644
--- a/README.md
+++ b/README.md
@@ -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
+`` 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
diff --git a/app/api/account/cart/route.ts b/app/api/account/cart/route.ts
index cde3c2c..1210285 100644
--- a/app/api/account/cart/route.ts
+++ b/app/api/account/cart/route.ts
@@ -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 });
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index 8dd2d64..b71616f 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -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,
diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
index f9abbc7..32fc171 100644
--- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
+++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
@@ -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
- {items.map(({ entry, product }) => (
-
+ {items.map(({ entry, product }) => {
+ const unitPrice = effectivePrice(entry, product);
+ const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
+ return (
+
-
{product.name}
+
+ {product.name}
+ {entry.variant ? ` (${entry.variant})` : ""}
+
- {entry.qty} × {formatPrice(product.price)} inkl. MwSt.
+ {entry.qty} × {formatPrice(unitPrice)} inkl. MwSt.
- {formatPrice(entry.qty * product.price)}
+ {formatPrice(entry.qty * unitPrice)}
- ))}
+ );
+ })}
diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index 6581360..686f2ad 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -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({
{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 (
-
+
{i > 0 &&
}
@@ -167,6 +174,7 @@ export function CartContent({
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
+ {entry.variant ? ` (${entry.variant})` : ""}
{product.description}
@@ -175,19 +183,20 @@ export function CartContent({
{discount !== null && (
{formatPrice(product.compareAtPrice!)}
)}
- {formatPrice(product.price)}
+ {formatPrice(unitPrice)}
inkl. MwSt.
-
+
Menge für {product.name}
+ {entry.variant ? ` (${entry.variant})` : ""}
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({
))}
- {formatPrice(entry.qty * product.price)}
+ {formatPrice(entry.qty * unitPrice)}
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"
>
×
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index 0904ef4..535e973 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -164,7 +164,7 @@ export function RelatedProducts() {
{product.name}
{formatPrice(product.price)}
-
+
))}
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index 4bc9143..30d594a 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -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
- {items.map(({ entry, product }) => (
-
+ {items.map(({ entry, product }) => {
+ const unitPrice = effectivePrice(entry, product);
+ const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
+ return (
+
-
{product.name}
+
+ {product.name}
+ {entry.variant ? ` (${entry.variant})` : ""}
+
- {entry.qty} × {formatPrice(product.price)} inkl. MwSt.
+ {entry.qty} × {formatPrice(unitPrice)} inkl. MwSt.
-
{formatPrice(entry.qty * product.price)}
+
{formatPrice(entry.qty * unitPrice)}
- ))}
+ );
+ })}
diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx
index 53c4d17..f9d584f 100644
--- a/app/components/AddToCartInlineButton.tsx
+++ b/app/components/AddToCartInlineButton.tsx
@@ -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
| undefined>(undefined);
const buttonRef = useRef(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 (
-
-
- {added ? "Hinzugefügt ✓" : label}
-
-
-
+
+ {variants.length > 0 && (
+ 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) => (
+
+ {v.name}
+
+ ))}
+
+ )}
+
+
+ {added ? "Hinzugefügt ✓" : label}
+
+
+
+
);
}
diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx
index f02d011..9564a0d 100644
--- a/app/konto/bestellungen/[orderNumber]/page.tsx
+++ b/app/konto/bestellungen/[orderNumber]/page.tsx
@@ -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
+ {order.trackingNumber && (
+
+
Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}
+ {(() => {
+ const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
+ return trackingUrl ? (
+
+ {order.trackingNumber}
+
+ ) : (
+
{order.trackingNumber}
+ );
+ })()}
+
+ )}
+
Lieferadresse
@@ -71,6 +88,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{item.quantity} × {item.productName}
+ {item.variantName ? ` (${item.variantName})` : ""}
{item.bundleContents &&
{item.bundleContents}
}
{item.returnQuantity > 0 && (
diff --git a/app/lib/__tests__/bundleContents.test.ts b/app/lib/__tests__/bundleContents.test.ts
index c9ff817..1720345 100644
--- a/app/lib/__tests__/bundleContents.test.ts
+++ b/app/lib/__tests__/bundleContents.test.ts
@@ -11,6 +11,7 @@ const product = (overrides: Partial
= {}): RawProduct => ({
image: null,
taxRatePercent: null,
bundleItems: null,
+ variants: null,
...overrides,
});
diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts
index 32816b7..024cfdd 100644
--- a/app/lib/__tests__/cartTotals.test.ts
+++ b/app/lib/__tests__/cartTotals.test.ts
@@ -17,6 +17,7 @@ const product = (overrides: Partial = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
+ variants: [],
...overrides,
});
diff --git a/app/lib/cart.ts b/app/lib/cart.ts
index 9d66c34..519189b 100644
--- a/app/lib/cart.ts
+++ b/app/lib/cart.ts
@@ -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 {
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.
diff --git a/app/lib/cartTotals.ts b/app/lib/cartTotals.ts
index f79671c..1bd1145 100644
--- a/app/lib/cartTotals.ts
+++ b/app/lib/cartTotals.ts
@@ -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 = {
diff --git a/app/lib/correctionInvoicePdf.tsx b/app/lib/correctionInvoicePdf.tsx
index 22cda51..a386242 100644
--- a/app/lib/correctionInvoicePdf.tsx
+++ b/app/lib/correctionInvoicePdf.tsx
@@ -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) => (
- {item.productName}
+
+ {item.productName}
+ {item.variantName ? ` (${item.variantName})` : ""}
+
{item.bundleContents ? {item.bundleContents} : null}
{effectiveQuantity}
diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts
index 1a41996..6af3627 100644
--- a/app/lib/customerAuth.ts
+++ b/app/lib/customerAuth.ts
@@ -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 {
@@ -353,19 +353,21 @@ export async function getServerCart(token: string): Promise {
});
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 {
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;
};
diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts
index 80eb0f8..5262837 100644
--- a/app/lib/emailTemplates.ts
+++ b/app/lib/emailTemplates.ts
@@ -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
: `
`
}
- ${escapeHtml(item.productName)} × ${item.quantity} ${item.bundleContents ? `${escapeHtml(item.bundleContents)} ` : ""}
+ ${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} × ${item.quantity} ${item.bundleContents ? `${escapeHtml(item.bundleContents)} ` : ""}
${formatPrice(item.quantity * item.unitPrice)}
`,
)
diff --git a/app/lib/invoicePdf.tsx b/app/lib/invoicePdf.tsx
index 69ece62..65d9374 100644
--- a/app/lib/invoicePdf.tsx
+++ b/app/lib/invoicePdf.tsx
@@ -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) => (
- {item.productName}
+
+ {item.productName}
+ {item.variantName ? ` (${item.variantName})` : ""}
+
{item.bundleContents ? {item.bundleContents} : null}
{item.quantity}
diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts
index 3979bc9..ba8238d 100644
--- a/app/lib/orderEmail.ts
+++ b/app/lib/orderEmail.ts
@@ -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,
diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts
index a583658..b9eacc6 100644
--- a/app/lib/orderServer.ts
+++ b/app/lib/orderServer.ts
@@ -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> {
diff --git a/app/lib/tracking.ts b/app/lib/tracking.ts
new file mode 100644
index 0000000..333fa39
--- /dev/null
+++ b/app/lib/tracking.ts
@@ -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 = {
+ dhl: "DHL",
+ dpd: "DPD",
+ hermes: "Hermes",
+ ups: "UPS",
+ gls: "GLS",
+ other: "Sonstiger Versanddienstleister",
+};
+
+const CARRIER_TRACKING_URL: Record 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;
+}
diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx
index 5a4abc7..8568437 100644
--- a/app/shop/components/ProductGrid.tsx
+++ b/app/shop/components/ProductGrid.tsx
@@ -82,7 +82,7 @@ export async function ProductGrid() {
equal-height lesson). */}
-
+
);