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
+43
View File
@@ -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 };
}