06abf1a6ae
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.
62 lines
2.1 KiB
TypeScript
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);
|
|
}
|