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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user