From 06abf1a6aefbee434f6e195c3063d0f8a9e2becb Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 21 Jul 2026 21:16:17 +0000 Subject: [PATCH] Add discount code feature (server-validated) and RelatedProducts polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discount codes: - New shared lib/cartTotals.ts (computeSubtotal/computeCartTotals) factored out of the previously-triplicated subtotal/totalSavings/total math in CartContent/CheckoutContent/BestellbestaetigungContent, extended to also fold in a discount amount (percent or fixed, clamped so total can't go negative). - lib/discount.ts mirrors lib/cart.ts's exact localStorage pattern so an applied code survives the /cart -> /checkout transition without a second input field — Checkout only displays it. - New /api/discount/validate (read-only check) and /api/discount/redeem (re-validates + increments the redemption counter, called once from checkout's handlePurchase right before the OrderSnapshot is written). Both talk to Payload's new discount-codes collection through lib/discountServer.ts, a server-only module kept separate from lib/payload.ts on purpose (that file is also imported by "use client" components; the RSC-boundary break hit earlier this session was exactly this mistake with next/headers). - OrderSnapshot gains discountCode/discountAmount so /bestellbestaetigung displays what was actually applied instead of losing it on recompute. RelatedProducts: no longer falls back to re-suggesting a product already in the cart just to pad the grid out to 3 cards — shows only the genuinely available remainder (down to 1 card), centered in the 12-column grid instead of left-aligned. --- app/api/discount/redeem/route.ts | 26 +++++ app/api/discount/validate/route.ts | 21 ++++ .../components/BestellbestaetigungContent.tsx | 27 ++++-- app/cart/components/CartContent.tsx | 82 ++++++++++++++-- app/cart/components/RelatedProducts.tsx | 56 ++++++----- app/checkout/components/CheckoutContent.tsx | 72 +++++++++++--- app/lib/cartTotals.ts | 43 +++++++++ app/lib/discount.ts | 61 ++++++++++++ app/lib/discountServer.ts | 96 +++++++++++++++++++ app/lib/order.ts | 5 + 10 files changed, 438 insertions(+), 51 deletions(-) create mode 100644 app/api/discount/redeem/route.ts create mode 100644 app/api/discount/validate/route.ts create mode 100644 app/lib/cartTotals.ts create mode 100644 app/lib/discount.ts create mode 100644 app/lib/discountServer.ts diff --git a/app/api/discount/redeem/route.ts b/app/api/discount/redeem/route.ts new file mode 100644 index 0000000..0b04a0e --- /dev/null +++ b/app/api/discount/redeem/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { validateDiscountCode, redeemDiscountCode } from "../../../lib/discountServer"; + +// Called once, from CheckoutContent.tsx's handlePurchase(), right before +// the OrderSnapshot is written — re-validates (the window/limit may have +// changed since the cart-side /validate check, however unlikely) and only +// then increments the redemption counter. If this fails, the caller must +// not complete the purchase with a dead code silently still showing as +// applied. +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const code = typeof body?.code === "string" ? body.code : ""; + const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0; + + if (!code) { + return NextResponse.json({ redeemed: false, reason: "Kein Code angegeben." }, { status: 400 }); + } + + const result = await validateDiscountCode(code, subtotal); + if (!result.valid) return NextResponse.json({ redeemed: false, reason: result.reason }); + + const ok = await redeemDiscountCode(result.doc); + if (!ok) return NextResponse.json({ redeemed: false, reason: "Rabattcode konnte nicht eingelöst werden." }); + + return NextResponse.json({ redeemed: true }); +} diff --git a/app/api/discount/validate/route.ts b/app/api/discount/validate/route.ts new file mode 100644 index 0000000..7c1a00c --- /dev/null +++ b/app/api/discount/validate/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { validateDiscountCode } from "../../../lib/discountServer"; + +// Called from CartContent.tsx when a shopper clicks "Anwenden" — read-only +// check (active/window/minOrderValue/remaining-redemptions), does NOT +// increment the redemption counter. That only happens in /redeem, at +// actual purchase time (see CheckoutContent.tsx's handlePurchase()). +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const code = typeof body?.code === "string" ? body.code : ""; + const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0; + + if (!code) { + return NextResponse.json({ valid: false, reason: "Bitte einen Code eingeben." }, { status: 400 }); + } + + const result = await validateDiscountCode(code, subtotal); + if (!result.valid) return NextResponse.json(result); + + return NextResponse.json({ valid: true, type: result.doc.type, value: result.doc.value }); +} diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx index 900c8f4..b659e39 100644 --- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx +++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import Image from "next/image"; import type { CartItem } from "../../lib/cart"; import { useProducts } from "../../lib/products"; -import { formatPrice, formatDate, discountPercent } from "../../lib/format"; +import { computeCartTotals } from "../../lib/cartTotals"; +import { formatPrice, formatDate } from "../../lib/format"; import { Reveal } from "../../components/Reveal"; import { CheckoutSteps } from "../../components/CheckoutSteps"; import { ORDER_KEY, type OrderSnapshot } from "../../lib/order"; @@ -26,7 +27,9 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null { typeof data.orderNumber !== "string" || typeof data.orderDateIso !== "string" || typeof data.shippingCost !== "number" || - typeof data.paymentMethodTitle !== "string" + typeof data.paymentMethodTitle !== "string" || + (data.discountCode !== null && typeof data.discountCode !== "string") || + typeof data.discountAmount !== "number" ) { return null; } @@ -95,12 +98,13 @@ export function BestellbestaetigungContent() { .map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) })) .filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product)); - const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0); - const totalSavings = items.reduce((sum, { entry, product }) => { - const discount = discountPercent(product.price, product.compareAtPrice); - return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum; - }, 0); - const total = subtotal + order.shippingCost; + // Displays the *persisted* discount from the snapshot, not a fresh + // re-derivation — the purchase already happened, this page is a + // receipt, not a live cart, so it doesn't re-validate the code at all. + const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, { + type: "fixed", + value: order.discountAmount, + }); return ( <> @@ -205,6 +209,13 @@ export function BestellbestaetigungContent() { -{formatPrice(totalSavings)} )} + {order.discountCode && ( +
+ Rabattcode ({order.discountCode}) + + -{formatPrice(order.discountAmount)} +
+ )}
Versand diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx index b32dcc8..dddd8bd 100644 --- a/app/cart/components/CartContent.tsx +++ b/app/cart/components/CartContent.tsx @@ -5,6 +5,8 @@ import Link from "next/link"; 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 { formatPrice, discountPercent } from "../../lib/format"; import { Reveal } from "../../components/Reveal"; import { VersandModal } from "../../components/VersandModal"; @@ -35,6 +37,10 @@ export function CartContent({ const [versandOpen, setVersandOpen] = useState(false); const cart = useCart(); const products = useProducts(); + const discount = useDiscount(); + const [discountInput, setDiscountInput] = useState(""); + const [discountError, setDiscountError] = useState(null); + const [discountLoading, setDiscountLoading] = useState(false); // While the /api/products fetch is still pending, treat a non-empty // cart as "loading" rather than "empty" — the old hardcoded PRODUCTS // lookup was synchronous, so this distinction didn't exist before; @@ -46,16 +52,37 @@ export function CartContent({ .map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) })) .filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product)); - const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0); - const totalSavings = items.reduce((sum, { entry, product }) => { - const discount = discountPercent(product.price, product.compareAtPrice); - return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum; - }, 0); + const subtotal = computeSubtotal(items); const shipping = items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold) ? 0 : shippingCost; - const total = subtotal + shipping; + const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount); + + async function handleApplyDiscount() { + const code = discountInput.trim(); + if (!code) return; + setDiscountLoading(true); + setDiscountError(null); + try { + const res = await fetch("/api/discount/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, subtotal }), + }); + const data = await res.json(); + if (data.valid) { + applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value }); + setDiscountInput(""); + } else { + setDiscountError(data.reason || "Dieser Code ist ungültig."); + } + } catch { + setDiscountError("Rabattcode konnte gerade nicht geprüft werden."); + } finally { + setDiscountLoading(false); + } + } return ( <> @@ -206,6 +233,49 @@ export function CartContent({
)} + {/* Rabattcode — cart-only, /checkout only displays the + already-applied result (see lib/discount.ts, shared via + localStorage the same way the cart itself is). */} + {discount ? ( +
+
+ Rabattcode ({discount.code}) + + -{formatPrice(discountAmount)} +
+ +
+ ) : ( +
+
+ + setDiscountInput(e.target.value)} + placeholder="Rabattcode" + className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors" + /> + +
+ {discountError &&

{discountError}

} +
+ )} +
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index 61b5221..0904ef4 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -16,25 +16,17 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri return shuffled.slice(0, count); } -// The catalog only has a handful of products — once the cart holds enough -// distinct ones, "N recommendations that aren't already in the cart" can -// become impossible (e.g. 4 products total, 2 already in the cart, but -// DISPLAY_COUNT is 3 — only 2 non-cart products exist, period). Falling -// back to re-suggesting something already in the cart (a normal "grab -// another one" pattern) beats silently shrinking the grid below -// DISPLAY_COUNT. Shared by both the initial pick and the swap-after-add -// path so neither can under-fill the grid. -function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] { +// Picks up to `count` active products not already in the cart, on top of +// whatever's already in `keep`. Deliberately does NOT fall back to +// re-suggesting a cart item when the non-cart pool runs short (e.g. 2 +// active products total, 1 already in the cart) — the grid just renders +// fewer, genuinely-relevant cards instead (see the centering logic in the +// component below), rather than padding itself out with something the +// shopper has already added. +function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] { const missing = count - keep.length; if (missing <= 0) return keep; - - let picks = pickRandom(allIds, [...excludeIds, ...keep], missing); - if (picks.length < missing) { - const stillMissing = missing - picks.length; - const fallback = pickRandom(allIds, [...keep, ...picks], stillMissing); - picks = [...picks, ...fallback]; - } - return [...keep, ...picks]; + return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)]; } export function RelatedProducts() { @@ -80,7 +72,7 @@ export function RelatedProducts() { if (!pickedRef.current) { pickedRef.current = true; - setDisplayIds(pickWithFallback(productIds, cartIds, [], DISPLAY_COUNT)); + setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT)); return; } @@ -92,7 +84,7 @@ export function RelatedProducts() { swapTimeoutRef.current = setTimeout(() => { setDisplayIds((prev) => { const stillRelevant = prev.filter((id) => !cartIds.includes(id)); - return pickWithFallback(productIds, cartIds, stillRelevant, DISPLAY_COUNT); + return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT); }); }, FEEDBACK_MS); }, [cartKey, productIds]); @@ -101,11 +93,9 @@ export function RelatedProducts() { .map((id) => activeProducts.find((p) => p.id === id)) .filter((p): p is NonNullable => Boolean(p)); - // Section-wide gate, independent of cart contents or how many - // displayProducts happen to resolve: with only 1 active product, - // pickWithFallback's cart-item-reuse fallback could still populate a - // card, but a "related products" section makes no sense with fewer than - // 2 real alternatives to offer. + // Section-wide gate, independent of cart contents: a "related products" + // section makes no sense with fewer than 2 active products total to + // ever offer, even before considering what's already in the cart. if (activeProducts.length < 2 || displayProducts.length === 0) return null; return ( @@ -138,10 +128,24 @@ export function RelatedProducts() { scroll-reveal nicety on a list that mutates; a static grid renders correctly with no animation risk. */}
- {displayProducts.map((product) => ( + {displayProducts.map((product, i) => (
(shippingMethods[0]?.id ?? null); const [paymentMethodId, setPaymentMethodId] = useState(paymentMethods[0]?.id ?? null); const [versandOpen, setVersandOpen] = useState(false); const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">("address"); + const [purchaseError, setPurchaseError] = useState(null); + const [purchasing, setPurchasing] = useState(false); const productsLoading = products.length === 0 && cart.length > 0; const items = cart .map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) })) .filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product)); - const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0); - const totalSavings = items.reduce((sum, { entry, product }) => { - const discount = discountPercent(product.price, product.compareAtPrice); - return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum; - }, 0); + const subtotal = computeSubtotal(items); const selectedShipping = shippingMethods.find((m) => m.id === shippingMethodId) ?? null; const freeShipping = selectedShipping?.freeShippingThreshold !== null && selectedShipping?.freeShippingThreshold !== undefined && subtotal >= selectedShipping.freeShippingThreshold; const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0; - const total = subtotal + shipping; + const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount); const selectedPayment = paymentMethods.find((m) => m.id === paymentMethodId) ?? null; // Captures the actually-selected shipping/payment method as the order // snapshot /bestellbestaetigung reads — see lib/order.ts's own comment, // there's no real order backend so this click IS what "placing the - // order" means here. - function handlePurchase() { + // order" means here. If a discount is applied, it must be re-validated + // and its redemption counter incremented server-side first (see + // app/api/discount/redeem/route.ts) — the code could have expired or hit + // its redemption cap since it was applied back in the cart. + async function handlePurchase(e: React.MouseEvent) { + if (discount) { + e.preventDefault(); + setPurchasing(true); + setPurchaseError(null); + try { + const res = await fetch("/api/discount/redeem", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: discount.code, subtotal }), + }); + const data = await res.json(); + if (!data.redeemed) { + setPurchaseError(data.reason || "Der Rabattcode konnte nicht final eingelöst werden."); + setPurchasing(false); + return; + } + } catch { + setPurchaseError("Der Rabattcode konnte gerade nicht geprüft werden."); + setPurchasing(false); + return; + } + } + const snapshot: OrderSnapshot = { items: cart, orderNumber: generateOrderNumber(), orderDateIso: new Date().toISOString(), shippingCost: shipping, paymentMethodTitle: selectedPayment?.title ?? "—", + discountCode: discount?.code ?? null, + discountAmount, }; try { window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot)); @@ -87,6 +118,10 @@ export function CheckoutContent({ // confirmation page falls back to its own empty state. } clearCart(); + clearDiscount(); + // Only needed for the discount path — the plain Link already handles + // navigation itself when its default wasn't prevented above. + if (discount) router.push("/bestellbestaetigung"); } if (!productsLoading && items.length === 0) { @@ -309,11 +344,16 @@ export function CheckoutContent({ - Jetzt kaufen (zahlungspflichtig) + {purchasing ? "Einen Moment…" : "Jetzt kaufen (zahlungspflichtig)"} + {purchaseError && ( +

{purchaseError}

+ )} +

Mit dem Kauf akzeptierst du unsere{" "} @@ -373,6 +413,16 @@ export function CheckoutContent({

)} + {/* Display-only — applied in /cart, this page has no input of + its own (see lib/discount.ts). */} + {discount && ( +
+ Rabattcode ({discount.code}) + + -{formatPrice(discountAmount)} +
+ )} +
diff --git a/app/lib/cartTotals.ts b/app/lib/cartTotals.ts new file mode 100644 index 0000000..f79671c --- /dev/null +++ b/app/lib/cartTotals.ts @@ -0,0 +1,43 @@ +import { discountPercent } from "./format"; +import type { Product } from "./payload"; + +// Previously duplicated independently in CartContent.tsx, CheckoutContent.tsx, +// and BestellbestaetigungContent.tsx — pulled into one place now that adding +// 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 DiscountLike = { type: "percent" | "fixed"; value: number }; + +// 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); +} + +export type CartTotals = { + subtotal: number; + /** compareAtPrice-based per-product savings — already excluded from + * `subtotal` (which uses `price`, not `compareAtPrice`), this is a + * separate display line only. */ + totalSavings: number; + discountAmount: number; + total: number; +}; + +export function computeCartTotals(items: CartLine[], shippingCost: number, discount?: DiscountLike | null): CartTotals { + const subtotal = computeSubtotal(items); + const totalSavings = items.reduce((sum, { entry, product }) => { + const productDiscountPct = discountPercent(product.price, product.compareAtPrice); + return productDiscountPct !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum; + }, 0); + const discountAmount = !discount + ? 0 + : discount.type === "percent" + ? (subtotal * discount.value) / 100 + : Math.min(discount.value, subtotal); + const total = Math.max(0, subtotal - discountAmount) + shippingCost; + return { subtotal, totalSavings, discountAmount, total }; +} diff --git a/app/lib/discount.ts b/app/lib/discount.ts new file mode 100644 index 0000000..71bb318 --- /dev/null +++ b/app/lib/discount.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +// Mirrors lib/cart.ts's exact pattern (localStorage + useSyncExternalStore) +// so an applied discount survives the /cart → /checkout transition the +// same way the cart itself does — Checkout only ever displays this, it +// has no input of its own (see CartContent.tsx for where a code gets +// applied via /api/discount/validate). +const DISCOUNT_KEY = "ep_discount"; +const DISCOUNT_EVENT = "ep-discount-updated"; + +export type AppliedDiscount = { code: string; type: "percent" | "fixed"; value: number }; + +function writeDiscount(discount: AppliedDiscount | null) { + if (discount) window.localStorage.setItem(DISCOUNT_KEY, JSON.stringify(discount)); + else window.localStorage.removeItem(DISCOUNT_KEY); + window.dispatchEvent(new Event(DISCOUNT_EVENT)); +} + +export function applyDiscount(discount: AppliedDiscount) { + writeDiscount(discount); +} + +// Called after a completed purchase (order.ts's clearCart() sibling) and +// by the cart's own "Entfernen" affordance. +export function clearDiscount() { + writeDiscount(null); +} + +// Cached-by-raw-string, not a fresh JSON.parse() every call — same +// reasoning as lib/cart.ts's getCart(): useSyncExternalStore requires +// getSnapshot to return the same reference when nothing actually changed. +let cachedRaw: string | null | undefined; +let cachedDiscount: AppliedDiscount | null = null; + +export function getDiscount(): AppliedDiscount | null { + if (typeof window === "undefined") return null; + const raw = window.localStorage.getItem(DISCOUNT_KEY); + if (raw === cachedRaw) return cachedDiscount; + cachedRaw = raw; + try { + cachedDiscount = raw ? JSON.parse(raw) : null; + } catch { + cachedDiscount = null; + } + return cachedDiscount; +} + +function subscribe(onStoreChange: () => void) { + window.addEventListener(DISCOUNT_EVENT, onStoreChange); + window.addEventListener("storage", onStoreChange); + return () => { + window.removeEventListener(DISCOUNT_EVENT, onStoreChange); + window.removeEventListener("storage", onStoreChange); + }; +} + +export function useDiscount(): AppliedDiscount | null { + return useSyncExternalStore(subscribe, getDiscount, () => null); +} diff --git a/app/lib/discountServer.ts b/app/lib/discountServer.ts new file mode 100644 index 0000000..bb6d68b --- /dev/null +++ b/app/lib/discountServer.ts @@ -0,0 +1,96 @@ +import { formatPrice } from "./format"; + +// Server-only — imported exclusively by app/api/discount/*/route.ts (Route +// Handlers are never bundled for the client anyway, but this file also +// touches DISCOUNT_SERVICE_SECRET, which must never end up reachable from +// a "use client" import graph). Kept out of lib/payload.ts on purpose, +// same reasoning as that file's own comment about staying free of +// next/headers — a shared module used by both server and client code is +// exactly where an accidental server-only dependency causes a build break. +const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de"; +const TENANT_SLUG = "einfach-produktiv"; +const SERVICE_SECRET = process.env.DISCOUNT_SERVICE_SECRET || ""; + +type PayloadDiscountCode = { + id: number; + code: string; + type: "percent" | "fixed"; + value: number; + validFrom: string | null; + validUntil: string | null; + minOrderValue: number | null; + maxRedemptions: number | null; + redemptionCount: number; + active: boolean; +}; + +async function fetchDiscountCode(code: string): Promise { + const params = new URLSearchParams({ + "where[tenant.slug][equals]": TENANT_SLUG, + "where[code][equals]": code.toUpperCase().trim(), + limit: "1", + }); + + const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, { + headers: { "x-discount-service-secret": SERVICE_SECRET }, + cache: "no-store", + }); + if (!res.ok) { + console.error(`fetchDiscountCode: Payload returned ${res.status} ${res.statusText}`); + return null; + } + + const data: { docs?: PayloadDiscountCode[] } = await res.json(); + return data.docs?.[0] ?? null; +} + +export type DiscountValidation = + | { valid: true; doc: PayloadDiscountCode } + | { valid: false; reason: string }; + +// Shared by both routes below — /validate calls this read-only when a +// shopper applies a code in the cart; /redeem calls it again immediately +// before incrementing the counter (the window/limit may have changed +// between the two, however unlikely), so neither route duplicates these +// rules independently. +export async function validateDiscountCode(code: string, subtotal: number): Promise { + const doc = await fetchDiscountCode(code); + if (!doc) return { valid: false, reason: "Dieser Code existiert nicht." }; + if (!doc.active) return { valid: false, reason: "Dieser Code ist nicht mehr gültig." }; + + const now = Date.now(); + if (doc.validFrom && now < new Date(doc.validFrom).getTime()) { + return { valid: false, reason: "Dieser Code ist noch nicht gültig." }; + } + if (doc.validUntil && now > new Date(doc.validUntil).getTime()) { + return { valid: false, reason: "Dieser Code ist abgelaufen." }; + } + if (doc.minOrderValue != null && subtotal < doc.minOrderValue) { + return { valid: false, reason: `Dieser Code gilt erst ab einem Bestellwert von ${formatPrice(doc.minOrderValue)}.` }; + } + if (doc.maxRedemptions != null && doc.redemptionCount >= doc.maxRedemptions) { + return { valid: false, reason: "Dieser Code wurde bereits zu oft eingelöst." }; + } + + return { valid: true, doc }; +} + +// Read-then-write, not an atomic conditional update — a true concurrent +// race on the very last redemption of a capped code has a narrow window +// where two requests could both pass validateDiscountCode() before either +// increments. Accepted, not worth custom atomic SQL for this shop's +// traffic level. +export async function redeemDiscountCode(doc: PayloadDiscountCode): Promise { + const res = await fetch(`${PAYLOAD_URL}/api/discount-codes/${doc.id}`, { + method: "PATCH", + headers: { + "x-discount-service-secret": SERVICE_SECRET, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redemptionCount: doc.redemptionCount + 1 }), + }); + if (!res.ok) { + console.error(`redeemDiscountCode: Payload returned ${res.status} ${res.statusText}`); + } + return res.ok; +} diff --git a/app/lib/order.ts b/app/lib/order.ts index a66e85b..06216d9 100644 --- a/app/lib/order.ts +++ b/app/lib/order.ts @@ -14,6 +14,11 @@ export type OrderSnapshot = { orderDateIso: string; shippingCost: number; paymentMethodTitle: string; + /** Persisted (not re-derived) so /bestellbestaetigung shows exactly what + * was actually applied at purchase time, not a fresh re-validation — + * null/0 when no discount was ever applied. */ + discountCode: string | null; + discountAmount: number; }; export function generateOrderNumber(): string {