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 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}

+

+ {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.

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

+

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