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:
Marco
2026-07-22 06:45:42 +00:00
parent 516945fc8c
commit 7f37f111e8
27 changed files with 1697 additions and 89 deletions
+202 -62
View File
@@ -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 &amp; 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} />
</>