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 };
}
+61
View File
@@ -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);
}
+96
View File
@@ -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<PayloadDiscountCode | null> {
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<DiscountValidation> {
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<boolean> {
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;
}
+5
View File
@@ -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 {