From 7f37f111e8323d7b3015bf53901b7024ae3d26b8 Mon Sep 17 00:00:00 2001
From: Marco
Date: Wed, 22 Jul 2026 06:45:42 +0000
Subject: [PATCH] Add real order persistence, customer accounts, and cart sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 94 ++++-
app/api/account/cart/route.ts | 34 ++
app/api/account/login/route.ts | 17 +
app/api/account/logout/route.ts | 7 +
app/api/account/me/route.ts | 8 +
app/api/account/orders/route.ts | 10 +
app/api/account/password/route.ts | 17 +
app/api/account/profile/route.ts | 50 +++
app/api/account/register/route.ts | 25 ++
app/api/checkout/route.ts | 151 ++++++++
.../components/BestellbestaetigungContent.tsx | 6 +
app/checkout/components/CheckoutContent.tsx | 264 ++++++++++----
app/checkout/page.tsx | 9 +-
app/components/CartSync.tsx | 38 ++
app/konto/bestellungen/[orderNumber]/page.tsx | 112 ++++++
app/konto/bestellungen/page.tsx | 84 +++++
app/konto/login/components/LoginForm.tsx | 87 +++++
app/konto/login/page.tsx | 24 ++
app/konto/profil/components/PasswordForm.tsx | 77 ++++
app/konto/profil/components/ProfileForm.tsx | 151 ++++++++
app/konto/profil/page.tsx | 31 ++
app/layout.tsx | 2 +
app/lib/cart.ts | 18 +
app/lib/customerAuth.ts | 332 ++++++++++++++++++
app/lib/order.ts | 14 +-
app/lib/orderServer.ts | 104 ++++++
app/lib/productsServer.ts | 20 ++
27 files changed, 1697 insertions(+), 89 deletions(-)
create mode 100644 app/api/account/cart/route.ts
create mode 100644 app/api/account/login/route.ts
create mode 100644 app/api/account/logout/route.ts
create mode 100644 app/api/account/me/route.ts
create mode 100644 app/api/account/orders/route.ts
create mode 100644 app/api/account/password/route.ts
create mode 100644 app/api/account/profile/route.ts
create mode 100644 app/api/account/register/route.ts
create mode 100644 app/api/checkout/route.ts
create mode 100644 app/components/CartSync.tsx
create mode 100644 app/konto/bestellungen/[orderNumber]/page.tsx
create mode 100644 app/konto/bestellungen/page.tsx
create mode 100644 app/konto/login/components/LoginForm.tsx
create mode 100644 app/konto/login/page.tsx
create mode 100644 app/konto/profil/components/PasswordForm.tsx
create mode 100644 app/konto/profil/components/ProfileForm.tsx
create mode 100644 app/konto/profil/page.tsx
create mode 100644 app/lib/customerAuth.ts
create mode 100644 app/lib/orderServer.ts
create mode 100644 app/lib/productsServer.ts
diff --git a/README.md b/README.md
index b9b496b..1f9a11b 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,12 @@ specific to this project.
image — see `AGENTS.md` before touching anything version-specific, this
Next.js release differs from older training-data conventions)
- **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation
-- No local database, no auth — all editable content comes from the shared
- Payload CMS at `payload.mk360.de` (see below)
+- No local database — all editable content (and now orders/customer
+ accounts) lives in the shared Payload CMS at `payload.mk360.de` (see
+ below). Customer auth is Payload's own (a second, separate `auth: true`
+ collection there, `customers` — not this app's own user store), bridged
+ via an httpOnly session cookie this app mints itself; see "Orders &
+ customer accounts" below.
## Getting started
@@ -34,9 +38,11 @@ the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the
same `https://payload.mk360.de` — only needed if the client-side Live
Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
(no safe default — required for discount codes to validate/redeem at all;
-must match the value set on the Payload backend). Set in Coolify's app
-settings for production, not in a committed `.env` — this app has no other
-secrets.
+must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
+(no safe default — required for `/api/checkout` to persist an order in
+Payload at all; must match the value set on the Payload backend). Set in
+Coolify's app settings for production, not in a committed `.env` — this app
+has no other secrets.
## Pages
@@ -48,6 +54,9 @@ secrets.
| `/cart` | Cart (localStorage-backed, see below) |
| `/checkout` | Shipping + payment method selection, order summary |
| `/bestellbestaetigung` | Order confirmation — reads the one-time snapshot `/checkout` wrote |
+| `/konto/login` | Customer login |
+| `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) |
+| `/konto/profil` | Profile/address editing + password change |
| `/challenge` | "Mini-Challenge" tool |
| `/todo-cards` | "Todo-Karten" tool |
| `/newsletter` | Newsletter signup |
@@ -182,24 +191,77 @@ check against Payload's public API, unlike most content on this site.
atomic against a true concurrent race on a capped code's very last
redemption — not worth custom atomic SQL at this shop's traffic level.
-## Cart & checkout — demo status
+## Cart & checkout
- **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in
- `localStorage` under `ep_cart`, keyed by each product's `slug`.
+ `localStorage` under `ep_cart`, keyed by each product's `slug`. Still the
+ source of truth while browsing — the server-side mirror (see below) only
+ exists to carry a logged-in customer's cart across devices/browsers.
- **`app/cart/components/RelatedProducts.tsx`** only ever suggests products
not already in the cart — it stopped falling back to re-suggesting an
already-in-cart product just to pad the grid out to 3 cards, so with a
small catalog it can render fewer cards (down to 1, centered in the
12-column grid) rather than recommending something already added.
-- **Checkout has no real backend for the *order* itself.** `/checkout`'s
- "Jetzt kaufen" click writes a one-time snapshot (chosen shipping/payment
- method, cart contents, applied discount) to `sessionStorage`
- (`app/lib/order.ts`), which `/bestellbestaetigung` reads once and
- displays — that snapshot *is* the order record. There is no payment
- processing, no persisted order in Payload or anywhere else, and no
- confirmation email yet — discount-code validation/redemption is the one
- part of this flow with real server-side enforcement today (see above).
- Treat the rest as a frontend/demo checkout flow, not a functioning store.
+- **`/checkout`'s "Jetzt kaufen" always goes through
+ `POST /api/checkout`.** That route re-prices the entire cart server-side
+ from Payload's live product data (never trusts client-submitted prices),
+ re-validates+redeems a discount code exactly once, registers a new
+ account inline if nobody's logged in yet ("Konto Pflicht" — see below),
+ and only then creates the order in Payload's `orders` collection via
+ `app/lib/orderServer.ts`. `app/lib/order.ts`'s `OrderSnapshot` is still
+ written to `sessionStorage` for `/bestellbestaetigung` to read once, but
+ its `orderNumber`/`orderDateIso` now come back from that Payload create
+ call, not generated client-side.
+- Still not built: real payment processing (the checkout button is
+ labelled "zahlungspflichtig" but nothing captures a payment) and a
+ transactional confirmation email — see `project_backend_checkout_plan`
+ in the assistant's own memory for what's deliberately deferred to a
+ later stage.
+
+## Orders & customer accounts
+
+An account is required to buy — there is no guest checkout. Registration
+happens inline in `/checkout`'s "1. Rechnungsadresse" card (a password
+field appears there when nobody's logged in); returning customers can
+instead expand a small "Schon Kundin? Einloggen" toggle in the same place
+without leaving the page.
+
+- **`app/lib/customerAuth.ts`** (server-only) is the single place that
+ talks to Payload's `customers` collection — a second, fully separate
+ `auth: true` collection from any admin login, existing purely for this
+ storefront's own accounts. Payload issues a JWT on register/login; this
+ app never relies on Payload's own auth cookie (different origin —
+ `einfach-produktiv.mk360.de` vs `payload.mk360.de`) and instead mints its
+ **own** httpOnly `ep_customer_token` cookie holding that JWT, forwarded
+ as an `Authorization: JWT ` header on every subsequent Payload
+ call. No token refresh in this stage — Payload's ~2h default JWT
+ lifetime means a session just expires and the customer logs in again.
+- **`app/api/account/*`** — thin route handlers around `customerAuth.ts`:
+ `register`, `login`, `logout`, `me`, `orders` (list), `profile`
+ (GET/PATCH incl. the one saved default address), `password` (verifies
+ the current password via a real login attempt before changing it,
+ doesn't just trust the caller), `cart` (GET/POST, see below).
+- **`/konto/bestellungen`** lists a customer's own orders;
+ **`/konto/bestellungen/[orderNumber]`** shows one order's full detail
+ (items, address, totals, `status`). `status` (`received` → `processing`
+ → `shipped` → `delivered`) is maintained by hand in the Payload admin —
+ no shipping-carrier API integration.
+- **`/konto/profil`** edits name + the one saved default address (deliberately
+ a single address, not a full address book — see the assistant's memory
+ note on optionally expanding this later) and changes the password.
+- **Cart sync**: `app/components/CartSync.tsx` (mounted once in
+ `app/layout.tsx`) watches the local cart via `useCart()` and
+ debounce-POSTs it to `/api/account/cart` on every change; the route
+ 401s (silently, by design) when nobody's logged in. On login
+ (`LoginForm.tsx`, and `CheckoutContent.tsx`'s inline toggle),
+ `mergeServerCartIntoLocal()` (`app/lib/cart.ts`) folds whatever was
+ saved server-side into the local cart by quantity — CartSync's own
+ effect then pushes the merged result back up on its own, so there's no
+ separate explicit "save after merge" call.
+- Only a single default address per account, single-currency, no order
+ cancellation/return flow, no email verification, no password-reset
+ (self-service — a customer who forgets their password currently has no
+ recovery path). All known, deliberately out of scope for now.
## Deployment
diff --git a/app/api/account/cart/route.ts b/app/api/account/cart/route.ts
new file mode 100644
index 0000000..cde3c2c
--- /dev/null
+++ b/app/api/account/cart/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import type { CartItem } from "../../../lib/cart";
+import { getServerCart, getSessionCustomer, saveServerCart } from "../../../lib/customerAuth";
+import { fetchProductsBySlug } from "../../../lib/productsServer";
+
+export async function GET() {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ cart: [] }, { status: 401 });
+ const cart = await getServerCart(session.token);
+ return NextResponse.json({ cart });
+}
+
+// Called by CartSync.tsx (debounced) on every local cart change while a
+// session is active — keeps the server-side mirror current so the cart
+// follows the customer across devices. Silently no-ops when logged out;
+// the caller doesn't care either way.
+export async function POST(request: Request) {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ ok: false }, { status: 401 });
+
+ const body = await request.json().catch(() => null);
+ const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
+
+ const productsBySlug = await fetchProductsBySlug();
+ const lines = cart
+ .map((item) => {
+ const product = productsBySlug.get(item.id);
+ return product ? { productId: product.id, productSlug: product.slug, quantity: item.qty } : null;
+ })
+ .filter((line): line is { productId: number; productSlug: string; quantity: number } => line !== null);
+
+ const ok = await saveServerCart(session.token, session.customer.id, lines);
+ return NextResponse.json({ ok });
+}
diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts
new file mode 100644
index 0000000..bbaad1e
--- /dev/null
+++ b/app/api/account/login/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
+
+export async function POST(request: Request) {
+ const body = await request.json().catch(() => null);
+ const email = typeof body?.email === "string" ? body.email : "";
+ const password = typeof body?.password === "string" ? body.password : "";
+ if (!email || !password) {
+ return NextResponse.json({ ok: false, reason: "Bitte E-Mail und Passwort angeben." }, { status: 400 });
+ }
+
+ const result = await loginCustomer({ email, password });
+ if (!result.ok) return NextResponse.json(result, { status: 401 });
+
+ await setSessionCookie(result.token);
+ return NextResponse.json({ ok: true, customer: result.customer });
+}
diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts
new file mode 100644
index 0000000..4c6ddf8
--- /dev/null
+++ b/app/api/account/logout/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from "next/server";
+import { clearSessionCookie } from "../../../lib/customerAuth";
+
+export async function POST() {
+ await clearSessionCookie();
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/api/account/me/route.ts b/app/api/account/me/route.ts
new file mode 100644
index 0000000..54e33b2
--- /dev/null
+++ b/app/api/account/me/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { getSessionCustomer } from "../../../lib/customerAuth";
+
+export async function GET() {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ customer: null }, { status: 401 });
+ return NextResponse.json({ customer: session.customer });
+}
diff --git a/app/api/account/orders/route.ts b/app/api/account/orders/route.ts
new file mode 100644
index 0000000..a1c869b
--- /dev/null
+++ b/app/api/account/orders/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from "next/server";
+import { getCustomerOrders, getSessionCustomer } from "../../../lib/customerAuth";
+
+export async function GET() {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ orders: [] }, { status: 401 });
+
+ const orders = await getCustomerOrders(session.token, session.customer.id);
+ return NextResponse.json({ orders });
+}
diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts
new file mode 100644
index 0000000..d9da1a0
--- /dev/null
+++ b/app/api/account/password/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
+
+export async function POST(request: Request) {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
+
+ const body = await request.json().catch(() => null);
+ const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
+ const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
+ if (!currentPassword || !newPassword || newPassword.length < 8) {
+ return NextResponse.json({ ok: false, reason: "Bitte aktuelles und ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
+ }
+
+ const result = await changeCustomerPassword(session.customer.email, currentPassword, newPassword);
+ return NextResponse.json(result, { status: result.ok ? 200 : 400 });
+}
diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts
new file mode 100644
index 0000000..1ac0e4d
--- /dev/null
+++ b/app/api/account/profile/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from "next/server";
+import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
+
+export async function GET() {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ profile: null }, { status: 401 });
+ return NextResponse.json({ profile: session.customer });
+}
+
+export async function PATCH(request: Request) {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
+
+ const body = await request.json().catch(() => null);
+ const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {};
+ if (
+ typeof firstName !== "string" ||
+ !firstName ||
+ typeof lastName !== "string" ||
+ !lastName ||
+ (deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
+ typeof zip !== "string" ||
+ !zip ||
+ typeof city !== "string" ||
+ !city ||
+ typeof country !== "string" ||
+ !country
+ ) {
+ return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
+ }
+ if (deliveryMethod === "address" && !street) {
+ return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
+ }
+ if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
+ return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
+ }
+
+ const result = await updateCustomerProfile(session.token, session.customer.id, {
+ firstName,
+ lastName,
+ deliveryMethod,
+ street,
+ packstationNumber,
+ postNumber,
+ zip,
+ city,
+ country,
+ });
+ return NextResponse.json(result, { status: result.ok ? 200 : 400 });
+}
diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts
new file mode 100644
index 0000000..1e212ef
--- /dev/null
+++ b/app/api/account/register/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
+
+export async function POST(request: Request) {
+ const body = await request.json().catch(() => null);
+ const { firstName, lastName, email, password } = body ?? {};
+ if (
+ typeof firstName !== "string" ||
+ typeof lastName !== "string" ||
+ typeof email !== "string" ||
+ typeof password !== "string" ||
+ !firstName ||
+ !lastName ||
+ !email ||
+ !password
+ ) {
+ return NextResponse.json({ ok: false, reason: "Bitte alle Felder ausfüllen." }, { status: 400 });
+ }
+
+ const result = await registerCustomer({ firstName, lastName, email, password });
+ if (!result.ok) return NextResponse.json(result, { status: 400 });
+
+ await setSessionCookie(result.token);
+ return NextResponse.json({ ok: true, customer: result.customer });
+}
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
new file mode 100644
index 0000000..7c96708
--- /dev/null
+++ b/app/api/checkout/route.ts
@@ -0,0 +1,151 @@
+import { NextResponse } from "next/server";
+import type { CartItem } from "../../lib/cart";
+import { getShippingMethods, getPaymentMethods } from "../../lib/payload";
+import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
+import { createOrder } from "../../lib/orderServer";
+import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
+import { fetchProductsBySlug } from "../../lib/productsServer";
+
+type CheckoutBody = {
+ cart: CartItem[];
+ shippingMethodId: number;
+ paymentMethodId: number;
+ discountCode: string | null;
+ firstName: string;
+ lastName: string;
+ email: string;
+ password?: string;
+ deliveryMethod: "address" | "packstation";
+ street?: string;
+ packstationNumber?: string;
+ postNumber?: string;
+ zip: string;
+ city: string;
+ country: string;
+ newsletterOptIn: boolean;
+};
+
+function isValidBody(body: unknown): body is CheckoutBody {
+ const b = body as Partial | null;
+ return Boolean(
+ b &&
+ Array.isArray(b.cart) &&
+ b.cart.length > 0 &&
+ typeof b.shippingMethodId === "number" &&
+ typeof b.paymentMethodId === "number" &&
+ typeof b.firstName === "string" &&
+ b.firstName &&
+ typeof b.lastName === "string" &&
+ b.lastName &&
+ typeof b.email === "string" &&
+ b.email &&
+ (b.deliveryMethod === "address" || b.deliveryMethod === "packstation") &&
+ typeof b.zip === "string" &&
+ b.zip &&
+ typeof b.city === "string" &&
+ b.city &&
+ typeof b.country === "string" &&
+ b.country,
+ );
+}
+
+export async function POST(request: Request) {
+ const body = await request.json().catch(() => null);
+ if (!isValidBody(body)) {
+ return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
+ }
+ if (body.deliveryMethod === "address" && !body.street) {
+ return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
+ }
+ if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
+ return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
+ }
+
+ // Auth: an existing session wins; otherwise this checkout submit doubles
+ // as inline registration ("Konto Pflicht, Registrierung direkt im
+ // Checkout") — logging in with an *existing* account happens separately
+ // beforehand via /api/account/login from the checkout page's own toggle.
+ let customer: CustomerSummary;
+ const session = await getSessionCustomer();
+ if (session) {
+ customer = session.customer;
+ } else {
+ if (!body.password) {
+ return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
+ }
+ const result = await registerCustomer({
+ firstName: body.firstName,
+ lastName: body.lastName,
+ email: body.email,
+ password: body.password,
+ });
+ if (!result.ok) return NextResponse.json(result, { status: 400 });
+ await setSessionCookie(result.token);
+ customer = result.customer;
+ }
+
+ // Re-price everything server-side — never trust client-submitted prices.
+ const productsBySlug = await fetchProductsBySlug();
+ const items: { productId: number; productName: string; quantity: number; unitPrice: number }[] = [];
+ for (const line of body.cart) {
+ const product = productsBySlug.get(line.id);
+ if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
+ items.push({ productId: product.id, productName: product.name, quantity: line.qty, unitPrice: product.price });
+ }
+ const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
+
+ const shippingMethods = await getShippingMethods();
+ const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
+ if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
+ const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
+ const shippingCost = freeShipping ? 0 : shippingMethod.price;
+
+ const paymentMethods = await getPaymentMethods();
+ const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
+ if (!paymentMethod) return NextResponse.json({ ok: false, reason: "Zahlungsart ist ungültig." }, { status: 400 });
+
+ let discountAmount = 0;
+ if (body.discountCode) {
+ const validation = await validateDiscountCode(body.discountCode, subtotal);
+ if (!validation.valid) return NextResponse.json({ ok: false, reason: validation.reason }, { status: 400 });
+ const redeemed = await redeemDiscountCode(validation.doc);
+ if (!redeemed) return NextResponse.json({ ok: false, reason: "Rabattcode konnte nicht eingelöst werden." }, { status: 400 });
+ discountAmount =
+ validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal);
+ }
+ const total = Math.max(0, subtotal - discountAmount) + shippingCost;
+
+ const order = await createOrder({
+ customerId: customer.id,
+ customerFirstName: body.firstName,
+ customerLastName: body.lastName,
+ customerEmail: body.email,
+ deliveryMethod: body.deliveryMethod,
+ street: body.street,
+ packstationNumber: body.packstationNumber,
+ postNumber: body.postNumber,
+ zip: body.zip,
+ city: body.city,
+ country: body.country,
+ newsletterOptIn: Boolean(body.newsletterOptIn),
+ items,
+ subtotal,
+ shippingCost,
+ shippingMethodTitle: shippingMethod.title,
+ paymentMethodTitle: paymentMethod.title,
+ discountCode: body.discountCode || null,
+ discountAmount,
+ total,
+ });
+ if (!order) return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
+
+ return NextResponse.json({
+ ok: true,
+ orderNumber: order.orderNumber,
+ orderDateIso: order.createdAt,
+ shippingCost,
+ paymentMethodTitle: paymentMethod.title,
+ discountCode: body.discountCode || null,
+ discountAmount,
+ });
+}
diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
index b659e39..f9abbc7 100644
--- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
+++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
@@ -263,6 +263,12 @@ export function BestellbestaetigungContent() {
)}
+
+
+ Meine Bestellungen ansehen
+
+
+
{/* Testimonial band — same proven structure as /not-found's version
(see that page's own comment), not a new layout: w-[45%] image
column, narrow 40px edge gradient into bg-muted, quote in the
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index dadeeb6..865e616 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -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(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState(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(null);
const [purchasing, setPurchasing] = useState(false);
+ const [showLogin, setShowLogin] = useState(false);
+ const [loginEmail, setLoginEmail] = useState("");
+ const [loginPassword, setLoginPassword] = useState("");
+ const [loginError, setLoginError] = useState(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) {
- 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) {
+ 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({
Fast geschafft! Nur noch ein paar Angaben.
+
+ {/* 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 ? (
+