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:
@@ -11,6 +11,7 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
variants: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+25
-10
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>`,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user