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,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called once, from CheckoutContent.tsx's handlePurchase(), right before
|
||||
// the OrderSnapshot is written — re-validates (the window/limit may have
|
||||
// changed since the cart-side /validate check, however unlikely) and only
|
||||
// then increments the redemption counter. If this fails, the caller must
|
||||
// not complete the purchase with a dead code silently still showing as
|
||||
// applied.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ redeemed: false, reason: "Kein Code angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json({ redeemed: false, reason: result.reason });
|
||||
|
||||
const ok = await redeemDiscountCode(result.doc);
|
||||
if (!ok) return NextResponse.json({ redeemed: false, reason: "Rabattcode konnte nicht eingelöst werden." });
|
||||
|
||||
return NextResponse.json({ redeemed: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called from CartContent.tsx when a shopper clicks "Anwenden" — read-only
|
||||
// check (active/window/minOrderValue/remaining-redemptions), does NOT
|
||||
// increment the redemption counter. That only happens in /redeem, at
|
||||
// actual purchase time (see CheckoutContent.tsx's handlePurchase()).
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ valid: false, reason: "Bitte einen Code eingeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json(result);
|
||||
|
||||
return NextResponse.json({ valid: true, type: result.doc.type, value: result.doc.value });
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice, formatDate, discountPercent } from "../../lib/format";
|
||||
import { computeCartTotals } from "../../lib/cartTotals";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
@@ -26,7 +27,9 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
typeof data.orderNumber !== "string" ||
|
||||
typeof data.orderDateIso !== "string" ||
|
||||
typeof data.shippingCost !== "number" ||
|
||||
typeof data.paymentMethodTitle !== "string"
|
||||
typeof data.paymentMethodTitle !== "string" ||
|
||||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
|
||||
typeof data.discountAmount !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -95,12 +98,13 @@ export function BestellbestaetigungContent() {
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const totalSavings = items.reduce((sum, { entry, product }) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
||||
}, 0);
|
||||
const total = subtotal + order.shippingCost;
|
||||
// Displays the *persisted* discount from the snapshot, not a fresh
|
||||
// re-derivation — the purchase already happened, this page is a
|
||||
// receipt, not a live cart, so it doesn't re-validate the code at all.
|
||||
const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, {
|
||||
type: "fixed",
|
||||
value: order.discountAmount,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -205,6 +209,13 @@ export function BestellbestaetigungContent() {
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.discountCode && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Versand</span>
|
||||
<span className="flex-1" />
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
@@ -35,6 +37,10 @@ export function CartContent({
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const discount = useDiscount();
|
||||
const [discountInput, setDiscountInput] = useState("");
|
||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||
const [discountLoading, setDiscountLoading] = useState(false);
|
||||
// While the /api/products fetch is still pending, treat a non-empty
|
||||
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
|
||||
// lookup was synchronous, so this distinction didn't exist before;
|
||||
@@ -46,16 +52,37 @@ export function CartContent({
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const totalSavings = items.reduce((sum, { entry, product }) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
||||
}, 0);
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const total = subtotal + shipping;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
|
||||
async function handleApplyDiscount() {
|
||||
const code = discountInput.trim();
|
||||
if (!code) return;
|
||||
setDiscountLoading(true);
|
||||
setDiscountError(null);
|
||||
try {
|
||||
const res = await fetch("/api/discount/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, subtotal }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
|
||||
setDiscountInput("");
|
||||
} else {
|
||||
setDiscountError(data.reason || "Dieser Code ist ungültig.");
|
||||
}
|
||||
} catch {
|
||||
setDiscountError("Rabattcode konnte gerade nicht geprüft werden.");
|
||||
} finally {
|
||||
setDiscountLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -206,6 +233,49 @@ export function CartContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rabattcode — cart-only, /checkout only displays the
|
||||
already-applied result (see lib/discount.ts, shared via
|
||||
localStorage the same way the cart itself is). */}
|
||||
{discount ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDiscount}
|
||||
className="self-start text-label text-text-muted hover:text-text-primary underline transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-2 w-full">
|
||||
<label className="sr-only" htmlFor="discount-code">Rabattcode</label>
|
||||
<input
|
||||
id="discount-code"
|
||||
type="text"
|
||||
value={discountInput}
|
||||
onChange={(e) => setDiscountInput(e.target.value)}
|
||||
placeholder="Rabattcode"
|
||||
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyDiscount}
|
||||
disabled={discountLoading || !discountInput.trim()}
|
||||
className="shrink-0 px-4 py-2 rounded-sm border border-border text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text-primary transition-colors"
|
||||
>
|
||||
{discountLoading ? "…" : "Anwenden"}
|
||||
</button>
|
||||
</div>
|
||||
{discountError && <p className="text-label text-red-600">{discountError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
|
||||
@@ -16,25 +16,17 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
// The catalog only has a handful of products — once the cart holds enough
|
||||
// distinct ones, "N recommendations that aren't already in the cart" can
|
||||
// become impossible (e.g. 4 products total, 2 already in the cart, but
|
||||
// DISPLAY_COUNT is 3 — only 2 non-cart products exist, period). Falling
|
||||
// back to re-suggesting something already in the cart (a normal "grab
|
||||
// another one" pattern) beats silently shrinking the grid below
|
||||
// DISPLAY_COUNT. Shared by both the initial pick and the swap-after-add
|
||||
// path so neither can under-fill the grid.
|
||||
function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
|
||||
// Picks up to `count` active products not already in the cart, on top of
|
||||
// whatever's already in `keep`. Deliberately does NOT fall back to
|
||||
// re-suggesting a cart item when the non-cart pool runs short (e.g. 2
|
||||
// active products total, 1 already in the cart) — the grid just renders
|
||||
// fewer, genuinely-relevant cards instead (see the centering logic in the
|
||||
// component below), rather than padding itself out with something the
|
||||
// shopper has already added.
|
||||
function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
|
||||
const missing = count - keep.length;
|
||||
if (missing <= 0) return keep;
|
||||
|
||||
let picks = pickRandom(allIds, [...excludeIds, ...keep], missing);
|
||||
if (picks.length < missing) {
|
||||
const stillMissing = missing - picks.length;
|
||||
const fallback = pickRandom(allIds, [...keep, ...picks], stillMissing);
|
||||
picks = [...picks, ...fallback];
|
||||
}
|
||||
return [...keep, ...picks];
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts() {
|
||||
@@ -80,7 +72,7 @@ export function RelatedProducts() {
|
||||
|
||||
if (!pickedRef.current) {
|
||||
pickedRef.current = true;
|
||||
setDisplayIds(pickWithFallback(productIds, cartIds, [], DISPLAY_COUNT));
|
||||
setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +84,7 @@ export function RelatedProducts() {
|
||||
swapTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayIds((prev) => {
|
||||
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
|
||||
return pickWithFallback(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
|
||||
return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
|
||||
});
|
||||
}, FEEDBACK_MS);
|
||||
}, [cartKey, productIds]);
|
||||
@@ -101,11 +93,9 @@ export function RelatedProducts() {
|
||||
.map((id) => activeProducts.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
||||
|
||||
// Section-wide gate, independent of cart contents or how many
|
||||
// displayProducts happen to resolve: with only 1 active product,
|
||||
// pickWithFallback's cart-item-reuse fallback could still populate a
|
||||
// card, but a "related products" section makes no sense with fewer than
|
||||
// 2 real alternatives to offer.
|
||||
// Section-wide gate, independent of cart contents: a "related products"
|
||||
// section makes no sense with fewer than 2 active products total to
|
||||
// ever offer, even before considering what's already in the cart.
|
||||
if (activeProducts.length < 2 || displayProducts.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -138,10 +128,24 @@ export function RelatedProducts() {
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product) => (
|
||||
{displayProducts.map((product, i) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
className={
|
||||
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
// explicit start column, later ones auto-flow right after
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "md:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "md:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCart, clearCart } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
@@ -42,43 +45,71 @@ export function CheckoutContent({
|
||||
* local computed shipping-cost value below. */
|
||||
shippingSettings: ShippingSettings;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const discount = useDiscount();
|
||||
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
|
||||
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">("address");
|
||||
const [purchaseError, setPurchaseError] = useState<string | null>(null);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
const productsLoading = products.length === 0 && cart.length > 0;
|
||||
const items = cart
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const totalSavings = items.reduce((sum, { entry, product }) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
||||
}, 0);
|
||||
const subtotal = computeSubtotal(items);
|
||||
const selectedShipping = shippingMethods.find((m) => m.id === shippingMethodId) ?? null;
|
||||
const freeShipping =
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const total = subtotal + shipping;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const selectedPayment = paymentMethods.find((m) => m.id === paymentMethodId) ?? null;
|
||||
|
||||
// Captures the actually-selected shipping/payment method as the order
|
||||
// snapshot /bestellbestaetigung reads — see lib/order.ts's own comment,
|
||||
// there's no real order backend so this click IS what "placing the
|
||||
// order" means here.
|
||||
function handlePurchase() {
|
||||
// order" means here. If a discount is applied, it must be re-validated
|
||||
// and its redemption counter incremented server-side first (see
|
||||
// app/api/discount/redeem/route.ts) — the code could have expired or hit
|
||||
// its redemption cap since it was applied back in the cart.
|
||||
async function handlePurchase(e: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (discount) {
|
||||
e.preventDefault();
|
||||
setPurchasing(true);
|
||||
setPurchaseError(null);
|
||||
try {
|
||||
const res = await fetch("/api/discount/redeem", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: discount.code, subtotal }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.redeemed) {
|
||||
setPurchaseError(data.reason || "Der Rabattcode konnte nicht final eingelöst werden.");
|
||||
setPurchasing(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setPurchaseError("Der Rabattcode konnte gerade nicht geprüft werden.");
|
||||
setPurchasing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: OrderSnapshot = {
|
||||
items: cart,
|
||||
orderNumber: generateOrderNumber(),
|
||||
orderDateIso: new Date().toISOString(),
|
||||
shippingCost: shipping,
|
||||
paymentMethodTitle: selectedPayment?.title ?? "—",
|
||||
discountCode: discount?.code ?? null,
|
||||
discountAmount,
|
||||
};
|
||||
try {
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
@@ -87,6 +118,10 @@ export function CheckoutContent({
|
||||
// confirmation page falls back to its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
// Only needed for the discount path — the plain Link already handles
|
||||
// navigation itself when its default wasn't prevented above.
|
||||
if (discount) router.push("/bestellbestaetigung");
|
||||
}
|
||||
|
||||
if (!productsLoading && items.length === 0) {
|
||||
@@ -309,11 +344,16 @@ export function CheckoutContent({
|
||||
<Link
|
||||
href="/bestellbestaetigung"
|
||||
onClick={handlePurchase}
|
||||
className="w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
aria-disabled={purchasing}
|
||||
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base ${purchasing ? "pointer-events-none opacity-70" : ""}`}
|
||||
>
|
||||
Jetzt kaufen (zahlungspflichtig)
|
||||
{purchasing ? "Einen Moment…" : "Jetzt kaufen (zahlungspflichtig)"}
|
||||
</Link>
|
||||
|
||||
{purchaseError && (
|
||||
<p className="text-label text-red-600 text-center w-full">{purchaseError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-label text-text-muted text-center w-full">
|
||||
Mit dem Kauf akzeptierst du unsere{" "}
|
||||
<Link href="/agb" target="_blank" rel="noopener noreferrer" className="underline hover:text-brand">
|
||||
@@ -373,6 +413,16 @@ export function CheckoutContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display-only — applied in /cart, this page has no input of
|
||||
its own (see lib/discount.ts). */}
|
||||
{discount && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user