Files
Marco 06abf1a6ae 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.
2026-07-21 21:16:17 +00:00

62 lines
2.1 KiB
TypeScript

"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);
}