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 +` 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)}

+
+ {variants.length > 0 && ( + + )} + +
); } 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). */}
- +
);