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
+78 -16
View File
@@ -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 <token>` 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