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
+26
View File
@@ -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 });
}
+21
View File
@@ -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 });
}