Add real order persistence, customer accounts, and cart sync
Checkout now persists orders server-side (Payload orders collection, re-priced from live product data, discount codes redeemed exactly once) instead of writing a client-only sessionStorage snapshot. Buying requires an account (registration inline in checkout, no separate step) — accounts get order history with delivery status, profile/address editing, password change, and a cart that syncs across devices while logged in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ 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 { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
|
||||
@@ -12,8 +12,9 @@ import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, generateOrderNumber, type OrderSnapshot } from "../../lib/order";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
import type { CustomerProfile } from "../../lib/customerAuth";
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
@@ -36,6 +37,8 @@ export function CheckoutContent({
|
||||
paymentMethods,
|
||||
trustBadges,
|
||||
shippingSettings,
|
||||
customerEmail,
|
||||
savedProfile,
|
||||
}: {
|
||||
shippingMethods: ShippingMethod[];
|
||||
paymentMethods: PaymentMethod[];
|
||||
@@ -44,6 +47,14 @@ export function CheckoutContent({
|
||||
* "shippingSettings", not "shipping", since that name is already the
|
||||
* local computed shipping-cost value below. */
|
||||
shippingSettings: ShippingSettings;
|
||||
/** From the checkout page's own session read (app/lib/customerAuth.ts) —
|
||||
* null means no account is logged in yet, which flips "1. Rechnungsadresse"
|
||||
* into inline-registration mode (password field shown, account created on
|
||||
* submit) since an account is required to buy. */
|
||||
customerEmail: string | null;
|
||||
/** Full profile (name + saved address) — null when logged out, pre-fills
|
||||
* Card 1's fields for a returning customer instead of leaving them blank. */
|
||||
savedProfile: CustomerProfile | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const cart = useCart();
|
||||
@@ -52,9 +63,14 @@ export function CheckoutContent({
|
||||
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 [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(savedProfile?.deliveryMethod ?? "address");
|
||||
const [purchaseError, setPurchaseError] = useState<string | null>(null);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [loginEmail, setLoginEmail] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
const productsLoading = products.length === 0 && cart.length > 0;
|
||||
const items = cart
|
||||
@@ -69,59 +85,104 @@ export function CheckoutContent({
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
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. 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.");
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
// down, flipping this form out of registration mode.
|
||||
async function handleLogin() {
|
||||
setLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: loginEmail, password: loginPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setLoginError(data.reason || "Login fehlgeschlagen.");
|
||||
setLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
router.refresh();
|
||||
} catch {
|
||||
setLoginError("Login ist gerade nicht möglich.");
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
// Always goes through /api/checkout now — that route re-prices
|
||||
// everything server-side, re-validates+redeems a discount code exactly
|
||||
// once (see its own comment), registers a new account inline when no
|
||||
// session exists yet ("Konto Pflicht"), and only then persists the order
|
||||
// in Payload. Replaces the old client-only sessionStorage snapshot.
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPurchasing(true);
|
||||
setPurchaseError(null);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const body = {
|
||||
cart,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
discountCode: discount?.code ?? null,
|
||||
firstName: String(form.get("firstName") ?? ""),
|
||||
lastName: String(form.get("lastName") ?? ""),
|
||||
email: String(form.get("email") ?? ""),
|
||||
password: customerEmail ? undefined : String(form.get("password") ?? ""),
|
||||
deliveryMethod,
|
||||
street: String(form.get("street") ?? "") || undefined,
|
||||
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
|
||||
postNumber: String(form.get("postNumber") ?? "") || undefined,
|
||||
zip: String(form.get("zip") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
newsletterOptIn: form.get("newsletterOptIn") === "on",
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setPurchaseError(data.reason || "Die Bestellung konnte nicht abgeschlossen 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));
|
||||
const snapshot: OrderSnapshot = {
|
||||
items: cart,
|
||||
orderNumber: data.orderNumber,
|
||||
orderDateIso: data.orderDateIso,
|
||||
shippingCost: data.shippingCost,
|
||||
paymentMethodTitle: data.paymentMethodTitle,
|
||||
discountCode: data.discountCode,
|
||||
discountAmount: data.discountAmount,
|
||||
};
|
||||
try {
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private browsing etc.) — the
|
||||
// confirmation page falls back to its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
router.push("/bestellbestaetigung");
|
||||
} catch {
|
||||
// sessionStorage unavailable (private browsing etc.) — the
|
||||
// confirmation page falls back to its own empty state.
|
||||
setPurchaseError("Die Bestellung konnte gerade nicht abgeschlossen werden.");
|
||||
setPurchasing(false);
|
||||
}
|
||||
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) {
|
||||
@@ -169,9 +230,57 @@ export function CheckoutContent({
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
|
||||
</div>
|
||||
|
||||
{/* Account gate — an account is required to buy, so this either
|
||||
confirms the active session or offers "already a customer?"
|
||||
login inline (registration itself happens as part of the main
|
||||
form submit below, via the password field in Card 1). */}
|
||||
{customerEmail ? (
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Eingeloggt als <span className="font-bold">{customerEmail}</span>{" "}
|
||||
<button type="button" onClick={handleLogout} className="underline hover:text-brand transition-colors">
|
||||
Abmelden
|
||||
</button>
|
||||
</p>
|
||||
) : !showLogin ? (
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Schon Kundin?{" "}
|
||||
<button type="button" onClick={() => setShowLogin(true)} className="underline font-bold hover:text-brand transition-colors">
|
||||
Hier einloggen
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
|
||||
<FormField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<FormField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={loggingIn}
|
||||
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loggingIn ? "…" : "Einloggen"}
|
||||
</button>
|
||||
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Form column */}
|
||||
<div className="w-full lg:flex-1 flex flex-col gap-6 items-start min-w-0">
|
||||
{/* 1. Rechnungsadresse */}
|
||||
@@ -183,19 +292,36 @@ export function CheckoutContent({
|
||||
1. Rechnungsadresse
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="Vorname" type="text" placeholder="Max" autoComplete="given-name" />
|
||||
<FormField label="Nachname" type="text" placeholder="Mustermann" autoComplete="family-name" />
|
||||
<FormField label="Vorname" name="firstName" type="text" defaultValue={savedProfile?.firstName} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" defaultValue={savedProfile?.lastName} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
</div>
|
||||
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
|
||||
actual rendered width in the 2-col row above (each half of
|
||||
a gap-4 flex row), instead of stretching full-width. */}
|
||||
<FormField
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={savedProfile?.email ?? customerEmail ?? undefined}
|
||||
placeholder="max@beispiel.de"
|
||||
autoComplete="email"
|
||||
required
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
{/* Only needed for the inline-registration path — an existing
|
||||
session already has an account, no password to collect. */}
|
||||
{!customerEmail && (
|
||||
<FormField
|
||||
label="Passwort (für dein neues Konto)"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Mind. 8 Zeichen"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
)}
|
||||
{/* Segmented control, same sm:w-[calc(50%-0.5rem)] half-row
|
||||
width as the field(s) below it — Lieferadresse keeps
|
||||
Straße und Hausnummer, Packstation swaps it out for
|
||||
@@ -234,37 +360,48 @@ export function CheckoutContent({
|
||||
{deliveryMethod === "address" ? (
|
||||
<FormField
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={savedProfile?.street ?? undefined}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
required
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Packstationnummer"
|
||||
name="packstationNumber"
|
||||
type="text"
|
||||
defaultValue={savedProfile?.packstationNumber ?? undefined}
|
||||
inputMode="numeric"
|
||||
placeholder="123"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
name="postNumber"
|
||||
type="text"
|
||||
defaultValue={savedProfile?.postNumber ?? undefined}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="PLZ" type="text" placeholder="10115" autoComplete="postal-code" />
|
||||
<FormField label="Ort" type="text" placeholder="Berlin" autoComplete="address-level2" />
|
||||
<FormField label="PLZ" name="zip" type="text" defaultValue={savedProfile?.zip ?? undefined} placeholder="10115" autoComplete="postal-code" required />
|
||||
<FormField label="Ort" name="city" type="text" defaultValue={savedProfile?.city ?? undefined} placeholder="Berlin" autoComplete="address-level2" required />
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
defaultValue="Deutschland"
|
||||
name="country"
|
||||
defaultValue={savedProfile?.country ?? "Deutschland"}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
<option>Deutschland</option>
|
||||
@@ -273,7 +410,11 @@ export function CheckoutContent({
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input type="checkbox" className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand" />
|
||||
<input
|
||||
type="checkbox"
|
||||
name="newsletterOptIn"
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="flex flex-col gap-1 text-body-sm text-text-primary">
|
||||
Ich möchte regelmäßig Impulse & Tipps per E-Mail erhalten.
|
||||
<span className="text-label text-text-muted">Du kannst dich jederzeit mit einem Klick abmelden.</span>
|
||||
@@ -341,14 +482,13 @@ export function CheckoutContent({
|
||||
</label>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href="/bestellbestaetigung"
|
||||
onClick={handlePurchase}
|
||||
aria-disabled={purchasing}
|
||||
<button
|
||||
type="submit"
|
||||
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" : ""}`}
|
||||
>
|
||||
{purchasing ? "Einen Moment…" : "Jetzt kaufen (zahlungspflichtig)"}
|
||||
</Link>
|
||||
</button>
|
||||
|
||||
{purchaseError && (
|
||||
<p className="text-label text-red-600 text-center w-full">{purchaseError}</p>
|
||||
@@ -508,7 +648,7 @@ export function CheckoutContent({
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user