Add discount code feature (server-validated) and RelatedProducts polish

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.
This commit is contained in:
Marco
2026-07-21 21:16:17 +00:00
parent 028a1fc4ec
commit 06abf1a6ae
10 changed files with 438 additions and 51 deletions
+76 -6
View File
@@ -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<string | null>(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({
</div>
)}
{/* 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 ? (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
</div>
<button
type="button"
onClick={clearDiscount}
className="self-start text-label text-text-muted hover:text-text-primary underline transition-colors"
>
Entfernen
</button>
</div>
) : (
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 w-full">
<label className="sr-only" htmlFor="discount-code">Rabattcode</label>
<input
id="discount-code"
type="text"
value={discountInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleApplyDiscount}
disabled={discountLoading || !discountInput.trim()}
className="shrink-0 px-4 py-2 rounded-sm border border-border text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text-primary transition-colors"
>
{discountLoading ? "…" : "Anwenden"}
</button>
</div>
{discountError && <p className="text-label text-red-600">{discountError}</p>}
</div>
)}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
+30 -26
View File
@@ -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<typeof p> => 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. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product) => (
{displayProducts.map((product, i) => (
<div
key={product.id}
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
className={
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
// explicit start column, later ones auto-flow right after
// it. 3-card case keeps the default left-to-right flow.
(i === 0
? displayProducts.length === 1
? "md:col-start-5"
: displayProducts.length === 2
? "md:col-start-3"
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image