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