ec75a480bd
Registering with an email that already has an account previously just failed with a generic error and no clear next step. registerCustomer() now flags emailExists specifically, and checkout switches straight to the login toggle (email pre-filled, scrolled into view) instead. The account icon also gets a small underline while logged in, matching the nav links' active-state styling — it was otherwise the only nav element that gave no visual signal of session state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
498 lines
32 KiB
Markdown
498 lines
32 KiB
Markdown
# einfach-produktiv — Frontend
|
|
|
|
Next.js frontend for [einfach-produktiv.mk360.de](https://einfach-produktiv.mk360.de),
|
|
Coolify-managed and deployed from this repo (`git.mk360.de/Marco/einfach-produktiv`).
|
|
For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the
|
|
shared Payload instance), see `~/dev/README.md` — this file only covers what's
|
|
specific to this project.
|
|
|
|
## Stack
|
|
|
|
- **Next.js 16.2.9** (App Router, `output: "standalone"` for a small Docker
|
|
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 — 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
|
|
|
|
```bash
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
Open [http://localhost:3000](http://localhost:3000). `npm run build && npm run
|
|
start` reproduces the production build locally — do this before pushing,
|
|
since Coolify builds with `--no-cache` and a failed build only surfaces there
|
|
otherwise.
|
|
|
|
**Environment variables:** `PAYLOAD_URL` (defaults to `https://payload.mk360.de`
|
|
if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe
|
|
default — required for Live Preview, see below; must match the value set on
|
|
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). `ORDER_SERVICE_SECRET`
|
|
(no safe default — required for `/api/checkout` to persist an order in
|
|
Payload at all, and for `/api/account/verify-email` to look up a customer
|
|
by their verification token; must match the value set on the Payload
|
|
backend — also used there for the same header). `SMTP_USER`/`SMTP_PASSWORD`
|
|
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
|
alerts and resend-verification emails; **does not** need to match anything
|
|
on the Payload side — this app's SMTP connection is deliberately
|
|
independent, see the "Monitoring & alerting" section). Set in Coolify's
|
|
app settings for production, not in a committed `.env`.
|
|
|
|
## Pages
|
|
|
|
| Route | Purpose |
|
|
|---|---|
|
|
| `/` | Home — hero, product spotlight, tools grid, trust row |
|
|
| `/shop` | Product grid (all active products for this tenant) |
|
|
| `/blog`, `/blog/[slug]` | Blog overview + post detail |
|
|
| `/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 |
|
|
| `/versand` | Shipping policy (timeframes, costs) |
|
|
| `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) |
|
|
|
|
## Content backend: Payload CMS
|
|
|
|
This app is one **tenant** in a shared, multi-tenant Payload instance also
|
|
used by other projects on the same VPS (see `docker/payload/` in the infra
|
|
repo). All content queries go through `app/lib/payload.ts`, which hardcodes
|
|
`TENANT_SLUG = "einfach-produktiv"` and filters every request with
|
|
`where[tenant.slug][equals]=einfach-produktiv` — the tenant field itself is
|
|
injected automatically into every collection below by Payload's
|
|
`multiTenantPlugin`, not defined in this app.
|
|
|
|
`/api/products` is this app's own same-origin proxy route (`app/api/products/route.ts`)
|
|
in front of `getProducts()` — used by client components (cart, related
|
|
products) that need the catalog reactively, so they don't talk to Payload's
|
|
API directly and reuse Next.js's fetch cache instead of an extra round trip.
|
|
|
|
### Collections used by this tenant
|
|
|
|
All reads are public (`access.read: () => true`) **except `discount-codes`**
|
|
(see its own row below); writes are admin-gated in the Payload admin UI at
|
|
`payload.mk360.de/admin`.
|
|
|
|
| Collection (slug) | Used for | Key fields |
|
|
|---|---|---|
|
|
| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`) |
|
|
| `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below |
|
|
| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) |
|
|
| `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) |
|
|
| `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF) |
|
|
| `trust-badges` | Horizontal "Schneller Versand / Versandkostenfrei / Mit Liebe verpackt" row — shown on `/shop`, `/cart`, `/checkout`, `/widerruf`, `/agb`, 404 | `title`, `description` (supports `{{lieferzeit}}`/`{{kostenfreiab}}` placeholders, resolved by the frontend from `shipping-settings`/`shipping-methods` at render time — not by Payload itself), `icon`, `sortOrder` |
|
|
| `cart-trust-badges` | Sidebar bullets on `/cart` (title only) and `/checkout` (title + description) — deliberately a separate collection from `trust-badges` so the two pages can't drift into showing different claims | `title`, `description`, `icon`, `sortOrder` |
|
|
| `shipping-methods` | `/checkout` shipping selection | `title` (no day-range in the title text — that lives in `shipping-settings` now, keeping both in one title used to drift), `description`, `price`, `freeShippingThreshold` (per-method, optional — leave empty for a method that should never be free, e.g. Express), `active` (inactive methods are hidden, not shown disabled), `sortOrder` |
|
|
| `shipping-settings` | Delivery-time disclosure shown on `/shop`, homepage spotlight, ToDo-Karten, `/cart`, `/checkout`, `/versand` (Art. 246a §1 Abs.1 Nr.8 EGBGB requires this visible before checkout) | `handlingDaysMin`/`handlingDaysMax` (processing time before it ships), `transitDaysMin`/`transitDaysMax` (carrier time) — the app derives the combined total itself. One row per tenant. |
|
|
| `payment-methods` | `/checkout` payment selection | `title`, `icons` (array — e.g. 3 logos for "Kreditkarte"), `active`, `sortOrder` |
|
|
| `werkzeuge-cards` | Homepage "Meine Werkzeuge" 3-card grid | `title`, `description`, `icon`, `ctaLabel`, `ctaHref`, `sortOrder` |
|
|
| `testimonials` | Customer testimonial grids on `/todo-cards`, `/newsletter`, `/challenge` | `quote`, `name`, `role`, `avatar`, `page` (`todo-cards`/`newsletter`/`challenge` — which page's grid this appears in), `sortOrder`. The single-quote "photo band" testimonials on `/not-found` and `/bestellbestaetigung` are a different shape (no avatar/role) and stay hardcoded, not part of this collection. |
|
|
| `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) |
|
|
|
|
All of the above (except `media`, `users`, `tenants`) are grouped in the
|
|
Payload admin sidebar under **Commerce** (`products`, `discount-codes`,
|
|
`shipping-methods`, `shipping-settings`, `payment-methods`, `trust-badges`,
|
|
`cart-trust-badges`) or **Content** (`posts`, `categories`, `legal-pages`,
|
|
`werkzeuge-cards`, `testimonials`); `media`/`users`/`tenants` sit under
|
|
**Platform** — `users` and `tenants` are hidden from non-super-admins' nav
|
|
entirely, and every tenant-scoped collection's own "assigned tenant" field
|
|
is hidden from non-super-admins in the edit view (though not yet the list
|
|
view's column — see the infra README's Payload CMS section for why that
|
|
one's a harder fix).
|
|
|
|
Editorial changes (prices, copy, images, toggling a shipping/payment method
|
|
on or off) all happen in the Payload admin UI — no code deploy needed. Adding
|
|
a *new field* to any collection above requires editing the collection file in
|
|
`docker/payload/src/collections/` and a migration, which does need a deploy
|
|
of the Payload service.
|
|
|
|
### Live Preview
|
|
|
|
`posts`, `legal-pages`, and `testimonials` support Payload's Live Preview —
|
|
opening a document in the Payload admin shows this app's real rendered page
|
|
in an iframe, updating as you type, no save required.
|
|
|
|
- **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET`
|
|
(matching value required on the Payload side too, or this route 401s) and
|
|
a `path`, enables Next.js Draft Mode, then redirects into the real page.
|
|
This is the link Payload's `livePreview.url` resolvers point at (see
|
|
`docker/payload/src/lib/previewUrl.ts` in the infra repo) — never the page
|
|
directly.
|
|
- Each supported page checks `draftMode().isEnabled` and renders a
|
|
`"use client"` Live-Preview-aware component instead of the plain static
|
|
one only when it's `true` — ordinary visitors are never in Draft Mode, so
|
|
they always get the plain version with zero extra client JS:
|
|
- `app/components/LiveRichText.tsx` — the 4 legal pages' body content
|
|
(their headings/sidebars are hardcoded per page, not CMS-sourced, so
|
|
`content` is the only field worth live-previewing there)
|
|
- `app/blog/[slug]/components/LivePostContent.tsx` — title/excerpt/
|
|
thumbnail/body of a blog post (the author bio card, "Weiterlesen" card,
|
|
and Footer stay static — they either aren't post-specific or are about
|
|
a *different* post, not the one open in the admin)
|
|
- `app/components/LiveTestimonialsGrid.tsx` — the one testimonial
|
|
currently open in the admin, merged by `id` into the rest of that
|
|
page's already-fetched grid (Live Preview is inherently single-document,
|
|
but this page renders several at once)
|
|
- All three use `@payloadcms/live-preview-react`'s `useLivePreview` hook and
|
|
reuse the same mapping functions (`mapPayloadPost`, `mapPayloadTestimonial`,
|
|
exported from `app/lib/payload.ts`) the plain server-side fetchers use, so
|
|
the two code paths can't silently drift apart.
|
|
- `app/lib/payload.ts` deliberately never imports `next/headers` itself —
|
|
callers (the Server Component pages) call `draftMode()` themselves and
|
|
pass the result in as a plain `{ draft: boolean }` option. Importing
|
|
`next/headers` anywhere in that module breaks the production build the
|
|
moment a `"use client"` component (which also needs this file's mapping
|
|
functions/types) tries to bundle it — a real RSC-boundary regression hit
|
|
once already when adding this feature.
|
|
|
|
## Discount codes
|
|
|
|
Applied in `/cart` only (`/checkout` displays the already-applied result,
|
|
no second input) — real server-side validation, not just a client-side
|
|
check against Payload's public API, unlike most content on this site.
|
|
|
|
- **No manual input field anymore** — the Rabattcode section on `/cart`
|
|
(`CartContent.tsx`) only renders at all when a code is actually applied;
|
|
there's no open "enter a code" box for every visitor (Nutzer-Entscheidung:
|
|
less visual noise, and codes are meant to be shared as marketing links,
|
|
not guessed/typed in). Instead, `?code=SAVE10` on the `/cart` URL
|
|
auto-applies once on arrival (a `useEffect` reading `useSearchParams()` —
|
|
requires `/cart`'s `page.tsx` to wrap `CartContent` in `<Suspense>`, a
|
|
Next.js requirement for any `useSearchParams()` consumer). A code that
|
|
arrives via the URL but turns out invalid/expired still shows an inline
|
|
error, just without an input box to attach it to.
|
|
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
|
|
two route handlers below — never by a `"use client"` component, same
|
|
reasoning as Live Preview's `next/headers` lesson above) talks to
|
|
Payload's `discount-codes` collection using an `x-discount-service-secret`
|
|
header (`DISCOUNT_SERVICE_SECRET`), since that collection isn't
|
|
public-read.
|
|
- **`app/api/discount/validate/route.ts`** — read-only check (active /
|
|
validity window / minimum order value / remaining redemptions), called
|
|
when a shopper clicks "Anwenden" in the cart.
|
|
- **`app/api/discount/redeem/route.ts`** — re-validates, then increments
|
|
the collection's `redemptionCount`. Called exactly once, from
|
|
`CheckoutContent.tsx`'s `handlePurchase()`, right before the
|
|
`OrderSnapshot` is written — a code that expired or hit its redemption
|
|
cap between being applied in the cart and the actual purchase click
|
|
fails the purchase with an inline error instead of silently completing.
|
|
- **`app/lib/discount.ts`** mirrors `lib/cart.ts`'s exact `localStorage` +
|
|
`useSyncExternalStore` pattern, so the applied code survives the
|
|
`/cart` → `/checkout` navigation the same way the cart itself does.
|
|
- **`app/lib/cartTotals.ts`** — `computeSubtotal()`/`computeCartTotals()`,
|
|
factored out of what used to be independently-duplicated subtotal/
|
|
savings/total math in `CartContent.tsx`, `CheckoutContent.tsx`, and
|
|
`BestellbestaetigungContent.tsx`; now also folds in the discount amount
|
|
(clamped so a total can never go negative). `OrderSnapshot` persists the
|
|
applied `discountCode`/`discountAmount` so the confirmation page shows
|
|
what actually happened, not a fresh re-derivation.
|
|
- Known, accepted limitation: the redeem route's read-then-increment isn't
|
|
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
|
|
|
|
- **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in
|
|
`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`'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.
|
|
- **Order confirmation email** is sent from `/api/checkout/route.ts`
|
|
right after a successful `createOrder()` — fire-and-forget
|
|
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
|
|
or fails the checkout response itself; a send failure alerts admin
|
|
instead (`sendCriticalAlert`, lower severity than the "order not
|
|
persisted" alert, since the order itself is safe either way). Content
|
|
comes from the **published** `order-confirmation` row in Payload's
|
|
`email-templates` collection — see "Email templates & Live Preview"
|
|
below for how that's edited/previewed.
|
|
- Still not built: real payment processing — the checkout button is
|
|
labelled "zahlungspflichtig" but nothing actually captures a payment
|
|
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
|
|
|
## 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.
|
|
- **`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),
|
|
`verify-email`, `resend-verification`, `delete`, `export` (see "Email
|
|
verification" and "GDPR self-service" below), `forgot-password`,
|
|
`reset-password` (see "Password reset" below), and
|
|
`orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
|
|
cancellation & returns" below).
|
|
- **`/konto/bestellungen`** lists a customer's own orders (status shown as
|
|
a colored `OrderStatusBadge.tsx`, plus an "Abmelden" link —
|
|
`LogoutButton.tsx`); **`/konto/bestellungen/[orderNumber]`** shows one
|
|
order's full detail (items, address, totals, `status`). `status`
|
|
(`received` → `processing` → `shipped` → `delivered`, plus
|
|
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
|
|
Payload admin for the shipping states — no shipping-carrier API
|
|
integration.
|
|
- **`Navbar.tsx`'s `AccountLink`** (account icon, desktop; "Anmelden"/"Mein
|
|
Konto" text link, mobile drawer) is the only *always*-reachable way into
|
|
`/konto/*` — added after discovering there previously wasn't one:
|
|
`/checkout`'s own login toggle only renders once the cart already has
|
|
items (its empty-cart state is an early return with no such toggle), and
|
|
`/bestellbestaetigung`'s "Meine Bestellungen ansehen" link only exists
|
|
after a completed order. A returning customer with an empty cart and no
|
|
recent order had no way to reach the login page at all before this.
|
|
Fetches auth state client-side via `/api/account/me` (not through the
|
|
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
|
|
static/ISR-cacheable — doesn't get forced into per-request dynamic
|
|
rendering just to know one icon's href; briefly shows the logged-out
|
|
state on first paint until that fetch resolves. The icon itself also
|
|
gets a small brand-colored underline while logged in — same visual
|
|
language as the desktop nav links' active-state indicator — since the
|
|
icon alone doesn't otherwise signal session state at a glance.
|
|
- **Checkout registration collisions**: if the email typed into Card 1
|
|
during inline registration already belongs to an existing account,
|
|
Payload's create call fails — `registerCustomer()` in `customerAuth.ts`
|
|
detects this specifically (`emailExists: true` on the returned
|
|
`AuthResult`, inferred from the field flagged in Payload's validation
|
|
error, since Payload's own message text doesn't distinguish "duplicate"
|
|
from other email-field failures) rather than just surfacing a generic
|
|
error. `CheckoutContent.tsx`'s `handleSubmit` reacts by switching
|
|
straight to the login toggle with that email pre-filled and
|
|
scroll-into-view, instead of leaving the customer stuck with an error
|
|
and no obvious next step.
|
|
- **`/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), changes the password, shows
|
|
the email-verification banner, and has the GDPR export/delete section.
|
|
- **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.
|
|
- Still out of scope: a full address book (single default address only —
|
|
see the assistant's memory note), single-currency.
|
|
|
|
### Rate limiting
|
|
|
|
`app/lib/rateLimit.ts` — an in-memory, per-IP sliding-window limiter
|
|
(`checkRateLimit(key, {limit, windowMs})`), deliberately no Redis: this
|
|
app runs as a single Coolify container, so a plain `Map` is enough and
|
|
needs no new infra. Resets on redeploy/restart — acceptable at this
|
|
shop's traffic level; revisit with a shared store if this ever scales to
|
|
multiple instances. Applied (keyed by `X-Forwarded-For`, which Caddy
|
|
already sets) to `/api/account/register`, `/api/account/login`,
|
|
`/api/account/password`, and `/api/account/resend-verification`.
|
|
|
|
This complements, not replaces, Payload's own **per-account** login
|
|
lockout (`customers.auth`, `maxLoginAttempts: 5` / `lockTime: 10min`,
|
|
Payload defaults — see the Payload README's "Login rate limiting"
|
|
section) — that stops brute-forcing one known email, this stops an IP
|
|
spraying attempts across many, or hammering registration.
|
|
|
|
### Session refresh
|
|
|
|
`proxy.ts` (project root — Next.js 16 renamed `middleware.ts` to
|
|
`proxy.ts`; see `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`
|
|
if this ever looks wrong against older docs/training data). Runs on
|
|
`/checkout`, `/konto/*`, `/api/account/*`. Decodes (not verifies — Payload
|
|
verifies for real on every actual API call) the `ep_customer_token`
|
|
cookie's JWT `exp` claim; if less than 15 minutes remain, silently calls
|
|
Payload's built-in `POST /api/customers/refresh-token` and swaps in the
|
|
refreshed token. Net effect: an actively-browsing customer never gets
|
|
logged out mid-session, but someone who walks away is logged out within
|
|
~2h of their last request (Payload's `tokenExpiration` default, unchanged
|
|
on the Payload side).
|
|
|
|
### Email verification
|
|
|
|
Non-blocking by design — see the Payload README's `customers.emailVerified`
|
|
section for why this is a custom flag rather than Payload's built-in
|
|
`auth.verify: true` (short version: that would hard-block login for a
|
|
brand-new customer trying to finish the purchase they just registered
|
|
mid-checkout for). The initial email is sent by Payload itself (an
|
|
`afterChange` hook on `customers`, fires on create). `/konto/profil`'s
|
|
`VerificationBanner.tsx` shows a non-blocking "bitte bestätigen" hint with
|
|
a resend link when `!profile.emailVerified`; resending
|
|
(`/api/account/resend-verification`) is sent directly from this app
|
|
instead (`app/lib/alertAdmin.ts`'s `sendVerificationEmail()` — same
|
|
Hostinger SMTP, no Payload hook to piggyback on for a plain field update).
|
|
|
|
### Password reset
|
|
|
|
Unlike email verification, this needed no custom flag — `forgotPassword`
|
|
doesn't block login, so it's Payload's built-in flow as-is (see the
|
|
Payload README's `customers.auth.forgotPassword` section), just with the
|
|
email content/destination swapped so the link points here instead of the
|
|
Payload admin. `/konto/passwort-vergessen` (`ForgotPasswordForm.tsx`) →
|
|
`POST /api/account/forgot-password` → always responds `{ok:true}`
|
|
regardless of whether the email exists (same anti-enumeration reasoning as
|
|
Payload's own operation — the route must not leak a different response
|
|
shape for "no such account", see its own comment). `/konto/passwort-zuruecksetzen?token=...`
|
|
(`ResetPasswordForm.tsx`, token read server-side from `searchParams` —
|
|
avoids needing a `<Suspense>` boundary, unlike the `?code=` cart case
|
|
above which genuinely needs client-side `useSearchParams()`) →
|
|
`POST /api/account/reset-password` → Payload logs the customer in on a
|
|
successful reset (returns the same `{token, user}` shape as login), so the
|
|
session cookie is set immediately, no separate login step. `LoginForm.tsx`
|
|
links to `/konto/passwort-vergessen`.
|
|
|
|
### Email templates & Live Preview
|
|
|
|
Both transactional emails (order confirmation, password reset) read their
|
|
subject/heading/body/footer wording from Payload's `email-templates`
|
|
collection — editable in the admin without a deploy, with a Live Preview
|
|
button using the exact same mechanism as Posts/LegalPages/Testimonials
|
|
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
|
|
dependency here for `LivePostContent.tsx`).
|
|
|
|
- **`app/lib/emailTemplates.ts`** — pure string-building functions
|
|
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`), no
|
|
server-only or client-only imports. Used **both** server-side for the
|
|
real send (`orderEmail.ts`) **and** client-side for the Live Preview
|
|
page — same function, same inputs, so a Live Preview edit and the real
|
|
sent email are guaranteed to render identically for order-confirmation
|
|
(password-reset's actual send uses Payload's own simple inline template
|
|
instead — see that repo's README for why — so its Live Preview
|
|
approximates rather than pixel-matches). Inline-styled HTML (`<table>`
|
|
layout, `style` attributes, no Tailwind/`<style>` block) — most email
|
|
clients strip external/embedded CSS.
|
|
- **`/email-preview/[type]/page.tsx`** — entered exclusively from Payload's
|
|
admin iframe (`EmailTemplates.ts`'s `admin.livePreview.url`), never a
|
|
real visitor destination (`noindex`). Always reads with `draft: true` so
|
|
an unsaved admin edit shows immediately. Renders against **sample data**
|
|
(`SAMPLE_ORDER` in `emailTemplates.ts`) — unlike the other three Live
|
|
Preview targets, there's no "current" real order/reset-link to preview
|
|
against generically.
|
|
- The *real* send always reads the **published** template
|
|
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
|
|
Preview edit never affects a live customer email until actually saved.
|
|
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
|
|
sensible defaults for both rows; `sendOrderConfirmationEmail()` also has
|
|
a hardcoded fallback for the rare case a fresh install's order arrives
|
|
before that seed has run.
|
|
|
|
### GDPR self-service
|
|
|
|
`/konto/profil`'s "Konto & Daten" section:
|
|
- **Export** (`/api/account/export`, GET) — profile + every order's full
|
|
detail as one downloadable JSON (`Content-Disposition: attachment`).
|
|
Genuinely complete, not a summary — Art. 20 data portability.
|
|
- **Delete** (`/api/account/delete`, POST, password re-verified via a real
|
|
login attempt first) — deletes the `customers` document. Past orders
|
|
are **not** touched: `orders.customer` is `ON DELETE SET NULL` in
|
|
Payload, so an order keeps its own name/address/items snapshot (already
|
|
stored independently for exactly this kind of reason) for tax-retention
|
|
purposes (§147 AO / GDPR Art. 17(3)(b) explicitly permits this) — only
|
|
the account/login itself disappears. The UI says this explicitly before
|
|
deleting, not as a surprise afterward.
|
|
|
|
### Order cancellation & returns
|
|
|
|
`/konto/bestellungen/[orderNumber]` shows one self-service button when
|
|
applicable: "Bestellung stornieren" while `status === 'received'`, or
|
|
"Rücksendung anfragen" while `status` is `'shipped'` or `'delivered'`
|
|
(`customerOrderAction()` in `customerAuth.ts` decides which, if any).
|
|
Posts to `/api/account/orders/[orderNumber]` (PATCH), which re-checks the
|
|
transition is still valid (friendlier error than a bare 403 if it's gone
|
|
stale — two tabs open, order shipped in the meantime) before calling
|
|
`requestOrderStatusChange()`.
|
|
|
|
**The real security boundary is in Payload**, not here: `orders.access.update`
|
|
already scoped a customer's JWT to their own order, but with no
|
|
field-level restriction — before this stage, a logged-in customer could in
|
|
principle PATCH *any* field of their own order (`total`, `items`,
|
|
anything), just because nothing in the frontend had ever exercised that
|
|
path yet. `Orders.ts`'s `beforeChange` hook now rejects a
|
|
customer-authenticated update unless the only changed field is `status`,
|
|
via an allowed transition. See the Payload README's own writeup for the
|
|
full detail.
|
|
|
|
No hard 14-day return-window check in code (no separately tracked delivery
|
|
date exists yet) — relies on the existing `/widerruf` legal text plus
|
|
manual admin review. No automatic refund (no payment provider exists yet)
|
|
— a return/cancellation request is just captured structurally instead of
|
|
arriving by email/phone; the admin still processes it by hand in the
|
|
Payload admin.
|
|
|
|
### Monitoring & alerting
|
|
|
|
Base uptime (is the site/Payload reachable at all) is already covered by
|
|
existing Uptime Kuma HTTP monitors with email alerting (`monitor.mk360.de`
|
|
— see `~/dev/README.md`'s Kuma section) and isn't part of this app. What's
|
|
new here is the one failure mode Kuma structurally can't see: the site is
|
|
up, a customer completes checkout, and the order still doesn't get
|
|
persisted (`createOrder()` returns `null` in `/api/checkout/route.ts`).
|
|
That path calls `app/lib/alertAdmin.ts`'s `sendCriticalAlert()` — its own,
|
|
independent SMTP connection (same Hostinger account, but **not** routed
|
|
through Payload, since Payload being the actual problem is one of the
|
|
scenarios this needs to still report on). Fire-and-forget, its own
|
|
try/catch, never blocks or fails the actual error response the customer
|
|
sees.
|
|
|
|
`/api/health` (GET) — checks Payload's public API is actually reachable
|
|
(3s timeout), not just that this page rendered; added as a Kuma HTTP
|
|
monitor in the existing "Content & API" group (`~/dev/README.md`'s
|
|
documented `sqlite3`-insert method, Kuma 1.x has no REST API for this).
|
|
|
|
## Deployment
|
|
|
|
- **Dockerfile**: 3-stage build (`deps` → `builder` → `runner`) with
|
|
BuildKit cache mounts, producing a ~100 MB standalone image. This is the
|
|
reference `Dockerfile` copied when scaffolding new projects on this VPS
|
|
(see the infra repo's new-project workflow).
|
|
- Deploys via `git push` → Gitea webhook → Caddy `/deploy/einfach-produktiv`
|
|
bridge → Coolify API → rebuild + restart. Full mechanics in
|
|
`~/dev/README.md`.
|
|
|
|
## Related design source
|
|
|
|
- `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs
|
|
- `~/dev/einfach-produktiv/styleguide.md` — design tokens (colors, type, spacing)
|
|
- `~/dev/einfach-produktiv/einfachproduktiv_figma_prompt_guide_v3.md` — Figma rebuild prompt guide
|