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
@@ -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() {
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
</div>
)}
{order.discountCode && (
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
<span className="flex-1" />
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
</div>
)}
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Versand</span>
<span className="flex-1" />