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