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
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { Reveal } from "../../../components/Reveal";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
export function PasswordForm({ email }: { email: string }) {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formEl = e.currentTarget;
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(formEl);
const currentPassword = String(form.get("currentPassword") ?? "");
const newPassword = String(form.get("newPassword") ?? "");
try {
const res = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Passwort konnte nicht geändert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
formEl.reset();
} catch {
setError("Passwort konnte gerade nicht geändert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Passwort ändern
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Hidden, but present so autofill/password managers correctly
associate the new password with this account's email. */}
<input type="hidden" name="email" value={email} autoComplete="username" />
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Aktuelles Passwort</span>
<input type="password" name="currentPassword" autoComplete="current-password" required className={inputClass} />
</label>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Neues Passwort</span>
<input type="password" name="newPassword" autoComplete="new-password" minLength={8} required className={inputClass} />
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Passwort geändert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Passwort ändern"}
</button>
</form>
</Reveal>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
import type { CustomerProfile } from "../../../lib/customerAuth";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
function Field({
label,
wrapperClassName = "flex-1 min-w-0",
...props
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input {...props} className={inputClass} />
</label>
);
}
export function ProfileForm({ profile }: { profile: CustomerProfile }) {
const router = useRouter();
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(false);
const form = new FormData(e.currentTarget);
const body = {
firstName: String(form.get("firstName") ?? ""),
lastName: String(form.get("lastName") ?? ""),
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") ?? ""),
};
try {
const res = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Profil konnte nicht gespeichert werden.");
setSaving(false);
return;
}
setSuccess(true);
setSaving(false);
router.refresh();
} catch {
setError("Profil konnte gerade nicht gespeichert werden.");
setSaving(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setDeliveryMethod("address")}
aria-pressed={deliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setDeliveryMethod("packstation")}
aria-pressed={deliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{deliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={profile.street ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
<Field label="Ort" name="city" type="text" defaultValue={profile.city ?? ""} required />
</div>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Land</span>
<select name="country" defaultValue={profile.country ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
</select>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Gespeichert.</p>}
<button
type="submit"
disabled={saving}
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
>
{saving ? "Speichert…" : "Speichern"}
</button>
</form>
</Reveal>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
export const metadata: Metadata = {
title: "Mein Profil",
robots: { index: false, follow: true },
};
export default async function KontoProfilPage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const profile = await getCustomerProfile(session.token);
if (!profile) redirect("/konto/login");
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<ProfileForm profile={profile} />
<PasswordForm email={profile.email} />
</div>
</main>
<Footer />
</>
);
}