Files
einfach-produktiv/README.md
T
Marco ec75a480bd Detect checkout email collisions and show login state in the navbar
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>
2026-07-22 08:26:00 +00:00

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