# 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
- **`@react-pdf/renderer`** for invoice PDF generation (see "Invoice
  PDFs" below) — no headless-browser dependency
- 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, for `/api/account/verify-email` to look up a customer
by their verification token, and for `getCompanySettings()` to read the
`company-settings` collection (seller data for invoice PDFs); 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). `STRIPE_SECRET_KEY`,
`STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`,
`PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing
(Stripe)" below. `BREVO_API_KEY`, `BREVO_LIST_ID`,
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — required for
newsletter signups to trigger a confirmation email at all),
`BREVO_DOI_REDIRECT_URL` (optional, defaults to `/newsletter-confirmed`) — see
"Newsletter signup" below. 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

Most reads are public (`access.read: () => true`); writes are always
admin-gated in the Payload admin UI at `payload.mk360.de/admin`. A growing
subset below is **not** public-read at all (`discount-codes`, `orders`,
`customers`, `number-ranges`, `company-settings`) — each row says so and
explains what does have access instead.

| 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()`), `taxRatePercent` (optional per-product VAT override, see "Product bundles & per-product tax rates" below), `bundleItems` (optional — makes this product a bundle) |
| `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) |
| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer |
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
| `email-templates` | Editable subject/heading/body/footer for all 9 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |

All of the above except `company-settings`, `media`, `users`, `tenants`
are grouped in the Payload admin sidebar under **Commerce** (`products`,
`discount-codes`, `orders`, `customers`, `number-ranges`,
`email-templates`, `shipping-methods`, `shipping-settings`,
`payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content**
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`).
`company-settings` sits in its own **Company** group; `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).

**What an admin can actually configure without a code deploy, at a
glance:** product/shipping/payment catalog data and `active` toggles
(incl. per-product tax-rate overrides and defining a product as a bundle),
discount codes, all page content (blog/legal/testimonials/trust badges),
delivery-time disclosure (`shipping-settings`), order/customer/invoice
numbering schemes (`number-ranges`), all 6 email wordings
(`email-templates`, with Live Preview), and invoice seller data,
bank details, and VAT rate (`company-settings` — also what every email's
legal footer is sourced from). What still requires a code change:
adding a new *field* to any collection (needs a migration), payment
processing itself (not built), and anything structural in
`orders`/`customers` beyond `status`/`returnReason` and the profile fields
already exposed on `/konto/profil`.

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. `email-templates`
also has Live Preview, but against a synthetic page + sample data rather
than one of these three's own real page — different enough to cover
separately, see "Email templates & Live Preview" further below.

- **`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.

- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
  "Anwenden" button, shown whenever no code is currently applied *and*
  Payload actually has at least one active code right now
  (`hasActiveDiscountCode()` in `discountServer.ts`, `where[active][equals]=true`,
  ISR-cached 60s — no point offering an open field that could never
  validate against anything). Once applied, the field is replaced by a
  read-only result + "Entfernen" link, shown regardless of that check (an
  already-applied code, e.g. from an older session, still needs somewhere
  to display even if no *other* code happens to be active right now).
  `?code=SAVE10` on the `/cart` URL still 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) regardless of `hasActiveDiscountCode()`
  too, so a marketing link still works without the shopper typing
  anything; if that auto-apply fails, the error shows even without the
  manual field present.
- **`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.
- **Add-to-cart is capped at actual remaining stock (fixed 2026-07-23)** —
  previously only checked at checkout, so a shopper could add more of a
  product than was actually in stock and only find out at the last step.
  `Product`/its variants now carry a real `maxQty` (`app/lib/payload.ts`'s
  `mapPayloadProduct()` — `null` when unlimited, i.e. backorder allowed or
  inventory untracked; a deliberate, narrow exception to that function's
  own "the public API has no reason to leak exact stock counts" comment,
  since the add-to-cart controls genuinely need it). `AddToCartButton`/
  `AddToCartInlineButton` disable (and show "Maximale Menge im Warenkorb")
  once the cart already holds that many; `/cart`'s quantity `<select>`
  caps its option range the same way instead of always offering a flat 1–9.
  `api/checkout/route.ts`'s own stock re-check stays as the authoritative
  server-side guard regardless.
- **`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`. `subtotal`/`discountAmount`/`total` are each
  rounded to 2 decimals (`roundMoney()`) right before being persisted —
  plain float arithmetic on a summed/percent-discounted cart drifts into
  values like `84.30000000000001`, invisible wherever a display already
  ran the number through `formatPrice()`'s `toFixed(2)`, but stored as-is
  otherwise and visible raw in the Payload admin's plain number field for
  `total`. `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()` for Überweisung orders only —
  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. As of the invoice PDF feature
  (see below), this same send also carries the order's invoice PDF as an
  attachment. Kreditkarte/PayPal orders defer this until payment is
  confirmed — see "Payment processing (Stripe)" below.

### Payment processing (Stripe)

Real payment capture for Kreditkarte/PayPal, via Stripe's Payment Element
(one integration covers both — see the approved plan this was built from,
`spicy-leaping-pizza.md`, for the full design rationale). Überweisung stays
exactly as before: no gateway involved, order goes straight to `received`.

- **`payment-methods`'s `provider` field** (Payload, admin-only) drives the
  branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal).
  `app/lib/payload.ts`'s `getPaymentMethods()` exposes it; the checkout
  route re-resolves it server-side, never trusts a client-submitted value.
- **Checkout UI collapses Kreditkarte + PayPal into one "Online-Zahlung"
  option** (`groupPaymentMethodsForCheckout()` in `app/lib/payload.ts`,
  used by `CheckoutContent.tsx`). Both admin rows still exist and both
  still need `provider: 'stripe'` — this is a display-layer grouping, not
  a data change. Reasoning: the PaymentIntent is created with
  `automatic_payment_methods: { enabled: true }` (Stripe's own recommended
  Payment Element pattern — Stripe itself decides which eligible method to
  show), so pre-selecting "Kreditkarte" vs. "PayPal" before that never
  actually restricted anything; it was redundant friction, not a real
  choice. The combined option shows a hint text ("die genaue Zahlungsart
  wählst du im nächsten Schritt") so the consolidation reads as intentional,
  not a missing option. Überweisung stays a separate, real option.
- **`paymentMethodTitle` is snapshotted as a neutral `"Online-Zahlung"`**
  at order-creation time for the `stripe` branch (the customer hasn't
  picked an instrument yet at that point) and **refined to the real one**
  (`"Kreditkarte"`/`"PayPal"`) once Stripe reports it —
  `resolveStripePaymentMethodLabel()` in `stripeProvider.ts` reads the
  confirmed PaymentIntent's `payment_method.type` in the webhook route and
  passes it to `confirm-payment` as an optional field. Best-effort: an
  unresolved label just leaves the neutral title in place. The
  `/checkout/verarbeitung` polling page also patches this into the
  provisional `sessionStorage` snapshot before promoting it, so
  `/bestellbestaetigung` shows the real instrument too, not the neutral
  placeholder.
- **`app/api/checkout/route.ts`, `provider === 'stripe'` branch**: creates
  a Stripe PaymentIntent *before* the order (`app/lib/payments/
  stripeProvider.ts`) — its id is known immediately and gets persisted as
  the order's own `providerReference` field at creation time, so the
  backend's abandonment-cleanup job can reconcile with Stripe later even
  if nothing else about this flow ever completes. The order is created
  with `status: 'pending_payment'`, `paymentStatus: 'pending'` — no
  invoice number yet, no confirmation email yet (both deferred to the
  webhook-driven confirm-payment step, on the backend). Right after, a
  best-effort (awaited, non-fatal) call attaches `{orderId, orderNumber}`
  as PaymentIntent metadata (`attachOrderMetadata`) — this is what lets
  the webhook resolve an incoming Stripe event back to a specific Payload
  order.
- **`app/checkout/components/PaymentStep.tsx`** renders in place of the
  address form once `/api/checkout` returns `requiresPayment: true` —
  Stripe's `PaymentElement` (real mode) or a "Testzahlung erfolgreich /
  fehlgeschlagen" button pair (test mode, see below). A card confirms
  in-place; PayPal (and 3-D-Secure challenges) redirect out and back via
  `return_url=/checkout/verarbeitung?orderNumber=...`.
- **`app/api/webhooks/stripe/route.ts`** — the real inbound webhook.
  Verifies `stripe-signature` against `STRIPE_WEBHOOK_SECRET`, reads the
  **raw** body (never `.json()` — the signature is computed over the exact
  bytes), handles `payment_intent.succeeded`/`.payment_failed`, and calls
  the backend's `POST /api/orders/:id/confirm-payment` (guarded by
  `PAYMENT_WEBHOOK_SECRET`, a secret distinct from `ORDER_SERVICE_SECRET`
  on purpose — least privilege, it can only hit this one action) with
  `{paymentStatus, providerReference, paidAt}`. Returns a non-2xx status on
  any internal failure so Stripe's own retry schedule (~3 days) provides
  resilience for free, rather than this app building its own retry queue.
  That backend endpoint flips the order to `received`, assigns the
  (until-then-deferred) invoice number, and queues the internal admin
  new-order notification — see the backend repo's own README for that
  half. It has no SMTP sender of its own, though: it returns a full order
  snapshot in its response instead, and **this webhook route is what
  actually sends the confirmation email + invoice PDF**
  (`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
  `alreadyProcessed: true` — a repeat webhook delivery must never resend
  it), mirroring exactly what the checkout route already does inline for
  a manual/Überweisung order. Product photos in that snapshot's
  `items[].imageUrl` need no frontend change to work —
  `ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
  field; the backend just wasn't populating it (fixed there, see its own
  README — needed `depth: 2` so `item.product.image` resolves to a real
  `Media` doc).
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
  `return_url` target. Neither a client-side `confirmPayment()` success nor
  landing back from a PayPal redirect is trusted as proof of payment on its
  own (a closed tab mid-redirect looks identical to success from here) —
  this page polls `/api/checkout/status?orderNumber=...` (session-scoped,
  so a guessed order number can't be used to probe someone else's payment
  status) until `paymentStatus` flips to `paid`, then promotes the
  provisional `sessionStorage` snapshot (`PENDING_ORDER_KEY`, written right
  before handing off to Stripe) to the real one (`ORDER_KEY`), clears the
  cart, and redirects to `/bestellbestaetigung` — exactly the same
  sessionStorage mechanism Überweisung orders already used, just populated
  a step later. On `failed`/`cancelled` it shows a retry message with the
  cart left intact (never cleared until payment actually succeeds); on a
  slow-to-arrive webhook it times out after ~15s with a "we'll email you"
  message rather than polling forever.

**Local testing without a real Stripe account** — `PAYMENT_TEST_MODE`
(defaults on whenever `STRIPE_SECRET_KEY` is unset, so a fresh `npm run dev`
never accidentally calls the real Stripe API): `app/lib/payments/index.ts`
swaps in `mockProvider.ts` instead of `stripeProvider.ts` — same interface,
so the checkout route and everything downstream of it runs unmodified.
`PaymentStep.tsx` shows "Testzahlung erfolgreich"/"Testzahlung
fehlgeschlagen" buttons instead of the real Payment Element; clicking one
calls `app/api/webhooks/stripe/test-confirm/route.ts`, which skips
signature verification (there's no real Stripe event to verify) and calls
the exact same backend `confirm-payment` endpoint the real webhook does —
so clicking "erfolgreich" exercises the *entire* real pipeline (deferred
invoice numbering, gated email, idempotency) end to end, it's only the
Stripe API call itself that's faked. That test-confirm route hard-404s
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
a reachable "mark any order paid" endpoint in production.

**Activating real Stripe payments — checklist.** Everything code-side is
already live (both `main` branches deployed); this is purely
provisioning. Nothing here is required to *test* the flow today —
`PAYMENT_TEST_MODE` already works end to end with zero Stripe account.

Already done, as of 2026-07-25:
- [x] `PAYMENT_WEBHOOK_SECRET` set in Coolify, matches the backend's copy
  in `docker/.env` — needed even in test mode (the test-confirm route
  sends it as a header the backend checks).
- [x] `payment-methods` rows configured: Kreditkarte/PayPal →
  `provider: 'stripe'`; "Überweisung (Vorkasse)" → `provider: 'manual'`
  (untouched by anything below); "Sofortüberweisung" prepared as
  `provider: 'stripe'` but `active: false` (needs a logo before switching
  on — see the backend's own README).
- [x] Database migration applied to production, confirmed live.

Still needed, in order:
1. **Create a Stripe account** (free). Stay in **test mode** first (toggle
   top of the Stripe dashboard) — nothing below moves real money until
   step 5.
2. **Copy the test API keys** — *Entwicklerbereich → API-Schlüssel*:
   `sk_test_...` and `pk_test_...`.
3. **Create a webhook endpoint** — *Entwicklerbereich → Webhooks → Endpoint
   hinzufügen*, URL `https://einfach-produktiv.mk360.de/api/webhooks/stripe`,
   events: at minimum `payment_intent.succeeded` and
   `payment_intent.payment_failed`. Copy the signing secret, `whsec_...`.
4. **Set these three in Coolify** (`einfach-produktiv` app → Environment
   Variables):
   | Variable | Value |
   |---|---|
   | `STRIPE_SECRET_KEY` | `sk_test_...` |
   | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_test_...` |
   | `STRIPE_WEBHOOK_SECRET` | `whsec_...` |

   The moment `STRIPE_SECRET_KEY` is set, `PAYMENT_TEST_MODE` switches off
   automatically (see above) — the real Payment Element replaces the mock
   buttons. Still 100% safe: Stripe's own test mode only accepts test card
   numbers (e.g. `4242 4242 4242 4242`), no real charge is possible.
5. **Set the same `sk_test_...` in the backend** too —
   `/home/marco/dev/docker/.env`'s `STRIPE_SECRET_KEY` (used only for
   `stripeRefund.ts`, Storno/Gutschrift refunds) — then
   `cd ~/dev/docker && docker compose build payload && docker compose up -d payload`
   to pick it up.
6. **Redeploy the frontend** so it picks up the new Coolify env vars —
   either wait for the next git push (Coolify redeploys on push) or
   trigger one directly: `curl -X POST https://coolify.mk360.de/deploy/einfach-produktiv`.
7. **Test end to end**: place a real order with a Stripe test card,
   confirm the order flips to `received`, invoice/confirmation email
   arrive, product images show, `Orders.paymentStatus` reads `paid`. Try a
   declined test card too (e.g. `4000 0000 0000 0002`) and confirm the
   order shows "Zahlung fehlgeschlagen" in the admin, not in the
   customer's own order history.
8. **Trigger a Storno on a paid test order** in the admin, confirm the
   refund job actually calls Stripe (check the PaymentIntent in the Stripe
   dashboard) and `Orders.refundStatus` updates.
9. **Go live**: only once ready for real charges — verify the Stripe
   account for live payments (business details), switch the dashboard to
   **live mode**, repeat steps 2–6 with the live-mode keys (`sk_live_...`/
   `pk_live_...`, a *new* webhook endpoint registered in live mode → new
   `whsec_...`) — these replace the test values in both Coolify and
   `docker/.env`, not additional variables.

### VAT display

Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
resolved rate (`app/lib/cartTotals.ts`'s `effectiveTaxRate(product,
defaultRate)` — a product's own `taxRatePercent` override if set,
otherwise the tenant default from `company-settings`), not a generic
"inkl. MwSt." disclosure — `getDefaultTaxRatePercent()` in
`app/lib/payload.ts` is a separate, ISR-cached (60s) fetch of just that one
number, deliberately not `getCompanySettings()` itself (that one is
`cache: "no-store"` for its invoice-generation callers, where always-fresh
bank details matter; the display rate only needs the same freshness every
other public catalog fetch already has).

A cart/checkout/order-confirmation *total* additionally shows the actual
€ amount of VAT included, not just a percentage — `computeTaxBreakdown()`,
imported from `@einfach-produktiv/invoicing` (a shared package consumed
by both this repo and the Payload backend as a git dependency — see
"Invoice PDFs" below; it used to be an independently-duplicated
`groupByTaxRate()` inside `invoicePdf.tsx`/`correctionInvoicePdf.tsx`,
both of which have since moved into that package too)
groups line items by their effective rate and reports each group's actual
tax amount; `app/components/VatBreakdown.tsx` renders a single "enthält
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
when it spans more than one. Used on `/cart`, `/checkout`, `/bestellbestaetigung`,
the order-confirmation email (`emailTemplates.ts`'s `renderOrderConfirmationHtml`,
which already carried per-item `taxRatePercent` but didn't render it
before), and both account order pages (see "Orders & customer accounts"
below) — the account order list additionally shows up to 4 product
thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
a plain product-id → image-url lookup separate from the slug-keyed
catalog, since an order only ever snapshots a numeric product id).

**None of this renders at all for a Kleinunternehmer tenant** (built
2026-07-24) — see "Kleinunternehmerregelung" below for the full list of
touched spots and why some read a live setting and others a persisted
per-order snapshot.

**Vorkasse instruction in the confirmation email (2026-07-25)** — the
invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own
`unpaidNoticeText`), but a customer often only glances at the email body
itself, not the attached PDF. `OrderConfirmationData` gained a required
`isManualPayment: boolean` field — **explicitly set by each caller**
(checkout route's manual branch: `true`; the Stripe webhook path:
always `false`, since only a *paid* Stripe order ever reaches that send at
all), deliberately **not** derived from `paymentMethodTitle` inside
`emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte",
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
payment-methods rename already broke once this session (see
`isPaidImmediately()` in the invoicing package). When `isManualPayment` is
true, `vorkasseNotice()` renders a full-width block (own row, `margin-top:
20px` — not squeezed into the Gesamtsumme table) naming the bank details
(`CompanySettings.bankName`/`iban`/`bic`, now also exposed on the
frontend's own `CompanySettings` type — previously only `iban`/`bic` were)
and the "processed within 1–2 business days after payment received" note.

### Checkout state persistence

`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
plain read/write functions (not `useSyncExternalStore` like `cart.ts`/
`discount.ts`: `CheckoutContent` is this draft's only reader, no
cross-component subscription to keep in sync). Every address-card field
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
shipping-address-override fields below, newsletter opt-in) plus the
selected shipping/payment method is now a controlled input backed by this
draft, restored on mount (a `useEffect`-deferred read, same SSR/hydration-
mismatch avoidance as `BestellbestaetigungContent`'s own sessionStorage
read) and cleared on a completed purchase. `password` is deliberately
excluded — stays a plain uncontrolled, unpersisted input.

### Optional deviating shipping address

"1. Rechnungsadresse" always collects a plain street address now — no
Lieferart (Lieferadresse/Packstation) toggle there anymore, since a
Packstation isn't a valid billing address for an invoice. A separate
checkbox ("Abweichende Lieferadresse verwenden") reveals a second address
section with its *own* delivery-method toggle (own name + delivery method +
street/Packstation/PLZ/Ort/Land) — that's the only place Packstation
delivery is offered at all. When used, the order's original address
fields stay the **billing** address (used for the invoice's "An" block
regardless), and the `shipping*`-prefixed fields (`hasDifferentShippingAddress`,
`shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/
`shippingStreet`/`shippingPackstationNumber`/`shippingPostNumber`/
`shippingZip`/`shippingCity`/`shippingCountry` — mirrored 1:1 on the
Payload `orders` collection, see the Payload README) determine where the
order actually ships. `/api/checkout/route.ts` validates the override the
same way it already validated the primary address (required fields,
street-xor-Packstation depending on the chosen delivery method). The
invoice PDF shows a third "Lieferadresse" address block alongside Von/An
when set (see "Invoice PDFs" below); `/konto/bestellungen/[orderNumber]`
shows both addresses too, relabeling the first one "Rechnungsadresse"
instead of "Lieferadresse" only once there's an actual second address to
distinguish it from.

### Destination countries (Payload-configurable)

Both country `<select>`s (billing, and the shipping-address override)
are fed by `getShippingCountries()` (`app/lib/payload.ts`) reading
Payload's `shipping-countries` collection (`name`, `plzDigits`,
`active`, `sortOrder`) — not a hardcoded array anymore. `plzDigits` also
drives PLZ's own `maxLength`/pattern validation (`validateZip()` in
`CheckoutContent.tsx` builds a `country → digit count` map from this
list), so an admin adding a country in Payload doesn't need a frontend
deploy to make it selectable, and the PLZ format check automatically
matches whatever digit count that country's row specifies. Seeded with
Deutschland (5 digits) and Österreich (4) — matching what this checkout
already offered before this became configurable. Adding a country here
(e.g. Schweiz) makes it immediately selectable at checkout; it does
**not** by itself add any customs/export-invoice handling or affect VAT
exemption eligibility (see "VAT exemption" below — that's still a
separate, deliberately-not-Payload-configurable legal decision).

### Product variants

A cart line's identity is `(id, variant)` together, not `id` alone —
`app/lib/cart.ts`'s `CartItem` gained an optional `variant?: string` field
(the selected `products.variants[].name`), and every function that used to
match a line by `id` (`addToCart`/`removeFromCart`/`setQuantity`) now
matches by both via a shared `sameLine()` helper, so two lines for the
same product with different variants stay genuinely separate entries
instead of merging or clobbering each other. `variant` undefined on both
sides (the common no-variants case) still matches by simple equality —
every pre-existing call site that never passes a variant keeps working
unchanged.

**Where a variant gets picked**: both `AddToCartInlineButton`
(`/shop`, `/cart`'s related-products grid) and `AddToCartButton` (the
marketing-page-specific one on `/todo-cards`' Hero + Pricing panel and the
homepage spotlight) render a `<select>` above the button when their
`variants` prop is non-empty, defaulting to the first *in-stock* variant.
All five call sites already fetch the full product server-side
(`getProducts()`/`getProductBySlug()`/`getSpotlightProduct()`), so
`product.variants` and `product.outOfStock` are simply passed straight
through — no separate data-fetch needed for `AddToCartButton`'s two pages.

**Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives
`Product.outOfStock` (and each `variants[].outOfStock`) from
`trackInventory`/`stock`/`allowBackorder` — true only when inventory is
tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart
buttons disable themselves and show "Ausverkauft" for whichever variant is
currently selected (or the plain product, when there are no variants);
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing any
discount/low-stock badge — a sold-out product has nothing else useful to
show) once *every* variant of a product is out — one sold-out variant
among several just reads as such in the picker itself, not as a
misleading blanket badge.

**Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives
`Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/
`stock`/`lowStockThreshold` the same way `outOfStock` is derived — true
only when inventory is tracked, stock is above zero (out-of-stock has its
own distinct badge, the two never combine), and at/below the product's own
`lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is
exposed in the public `Product` type, only this derived boolean — the
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
verfügbar" **text line** (`text-warning`, the same `--color-warning` token
in `globals.css`, distinct from the brand-colored discount badge) next to
the price on `ProductGrid.tsx`/`ProductSpotlight.tsx`/`RelatedProducts.tsx`/
todo-cards' `TodoKartenHero.tsx` and `Pricing.tsx` (both — the page has two
independent purchase CTAs, hero and pricing panel, each with their own
price/delivery block) and — variant-specific, not
"any variant low" — under the product name in `CartContent.tsx`'s own
cart line items, plus the existing `"(nur noch wenige)"` variant-select
suffix in `AddToCartButton`/`AddToCartInlineButton`. The image-overlaid
top-left pill (`position: absolute`, outside layout flow) now shows only
Ausverkauft/discount — low-stock moved off the image into text on request,
since a shopper scanning product photos for "-20%" style badges reads a
long low-stock sentence pinned to the image as clutter, and a customer
already in the cart with that product had no low-stock signal there at
all before this. The earlier version of this text line (removed once,
see git history) broke equal-height card alignment in `ProductGrid.tsx`/
`RelatedProducts.tsx` by only rendering when `lowStock` was true, so
cards with/without the line ended up different heights; this version
always renders the line's slot (`min-h-[1.05rem]`, empty when not
low-stock) so every card in a row reserves the same space regardless of
state — `ProductGrid.tsx` additionally still has its `flex-1` spacer
pinning the add-to-cart button to the same Y as before, so the reserved
height is belt-and-suspenders there, but load-bearing in
`RelatedProducts.tsx`, which has no such spacer. Only "Ausverkauft" still
wins outright over the discount pill, since it replaces it.

**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
a selected variant's `priceOverride` wins over the base `product.price`
(falling back to it when unset or no variant selected). Every cart/
checkout/order-confirmation total (`computeSubtotal`, `computeCartTotals`,
and each page's own per-line price display in `CartContent.tsx`,
`CheckoutContent.tsx`, `BestellbestaetigungContent.tsx`) goes through this
instead of reading `product.price` directly. The checkout route
(`/api/checkout/route.ts`) re-validates the requested variant server-side
too — same "never trust the client" reasoning as price re-derivation
generally: a `line.variant` naming something that doesn't exist on that
product (removed, or a tampered request) fails the whole checkout rather
than silently falling back to the base price. It also re-checks stock at
that same point — depth-in-defense, not just the disabled button UI above
— rejecting the order when the resolved product/variant has
`trackInventory` on, `allowBackorder` off, and less `stock` than the
requested quantity.

**Snapshotting**: `orders.items[].variantName` captures which variant was
picked at order time (same "snapshot, not a live relationship" reasoning
as `bundleContents`) — shown as a parenthetical next to the product name
on the order-confirmation email, both invoice PDF types, and the
order-detail page. The server-side cart mirror
(`Customers.cart[].variantName`, synced via `/api/account/cart`) carries
the same field so a variant selection survives a login/logout cycle, not
just the current session.

See the Payload README's "Inventory & product variants" section for the
backend data model (`products.variants`, stock bookkeeping) this all
builds on.

### Tracking numbers

`orders.carrier`/`trackingNumber` (admin-entered in Payload, no carrier
API) show as a clickable link on `/konto/bestellungen/[orderNumber]` when
set — `app/lib/tracking.ts`'s `buildTrackingUrl(carrier, trackingNumber)`
mirrors the Payload backend's own copy of this file byte-for-byte close
(same carrier set/URL patterns, kept in sync by hand, no shared package
between the two deployments) so the link an admin sees generated in the
`order-shipped` email matches exactly what a customer sees here. Falls
back to plain (non-linked) text for `carrier: 'other'`, which has no known
URL pattern.

### Product bundles & per-product tax rates

Both resolved server-side in `/api/checkout/route.ts`, at the same point
prices are already being re-derived from live Payload data (never trusted
from the client):

- **Tax rate**: `product.taxRatePercent ?? companySettings.taxRatePercent`
  — a product's own override if set, otherwise the tenant-wide default
  from `company-settings` (fetched alongside the product catalog,
  `Promise.all([fetchProductsBySlug(), getCompanySettings()])`). Snapshotted
  onto `orders.items[].taxRatePercent` at order creation — see the Payload
  README's "Per-product tax rates" section for why this has to be a
  snapshot, not a live lookup.
- **Bundles**: `describeBundleContents()` resolves a product's
  `bundleItems` (Payload relationship, populated via `fetchProductsBySlug()`'s
  `depth: 2` fetch — one level deeper than the `depth: 1` `image` alone
  needs, since `bundleItems.product` is a relationship nested inside an
  array field) into a plain string like `"2× ToDo-Karten, 1× Wochenplaner"`,
  snapshotted onto `orders.items[].bundleContents`. A bundle is otherwise
  just a regular product everywhere else in this app — same cart/checkout/
  pricing code path, no special-casing needed, since it's just a product
  with an extra field (see the Payload README's "Product bundles" section
  for why it's modeled that way instead of a separate collection).

## Invoice PDFs

Generated **synchronously at checkout** and attached to the order
confirmation email — not just an on-demand download — per an explicit
product decision that a customer should always have the invoice in their
inbox, not only in `/konto/bestellungen`.

- **`@einfach-produktiv/invoicing`** — a small standalone package
  (`git.mk360.de/Marco/einfach-produktiv-invoicing`, public repo, no
  secrets in it) holding every invoice/correction-invoice renderer plus
  `computeTaxBreakdown()` (see "VAT display" above) and shared
  formatters, consumed here **and** by the Payload backend as a git
  dependency (`"@einfach-produktiv/invoicing":
  "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"`
  in `package.json`). Ships raw TS/TSX source, no build step of its own —
  this app's `next.config.ts` lists it under `transpilePackages` so this
  app's own bundler compiles it, same as first-party code, the same
  pattern a monorepo tool like Turborepo uses for internal packages
  without actually needing a monorepo. Before this package existed
  (2026-07-23), the correction-invoice renderer was hand-duplicated
  between this repo and the backend, "kept in sync by eye" — that already
  caused three real, customer-visible drifts (a silently dropped
  `variantName`, a footer that wasn't `position: fixed`, a numeric vs.
  spelled-out date format) before the dedup, see that package's own
  README for the specifics.
- **`InvoiceDocument`** — a `@react-pdf/renderer` `Document`, not
  HTML-to-PDF or a headless browser (Puppeteer/Chromium would be a
  heavier footprint on a VPS already running several other containers).
  Built-in Helvetica rather than a registered web font — this renders
  inside a fire-and-forget checkout step, and a font-fetch failure there
  would be one more way to silently lose the attachment for no real
  design benefit; brand color/spacing still carries the visual identity
  via `StyleSheet`.
- **Layout**: a header separated by a bold brand-colored rule (not a
  filled color band — a plain line reads cleaner than a solid block of
  color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
  addresses, invoice number/date/order-reference/USt-IdNr. shown as small
  bordered "meta boxes" rather than a plain text row, a rounded/bordered
  item table, and a shaded summary card for the totals — deliberately
  closer to the site's own card-based UI language than a generic invoice
  template. The footer is pinned to the bottom of the page
  (`position: absolute` + react-pdf's `fixed` prop), not just wherever the
  content flow happens to end. Item rows share one uniform tinted
  background now (fixed 2026-07-23) — no more alternating white/tinted
  zebra striping.
- **"Bereits beglichen" confirmation**: shown next to the summary card
  whenever `order.paymentMethodTitle` is anything other than
  `"Überweisung"` (bank transfer) — Kreditkarte and PayPal both settle at
  checkout, so the invoice says so explicitly (`isPaidImmediately()`, in
  the shared package's `invoicePdf.tsx` — "Überweisung" is the one method
  named explicitly as the exception, rather than hardcoding a list of
  "immediate" titles that would need updating every time a new payment
  method is added in Payload). Plain green text, not a tinted pill/badge
  box (fixed 2026-07-23 — a colored background read as too heavy for what's
  just a status note). This one genuinely is conditional on the order's
  own payment method — unlike bank details below, which just used to be
  worded as if it were.
- **Bank details**: `company-settings.bankName`/`.iban`/`.bic` — `iban`/
  `bic` structured and independently format-validated (uppercased/trimmed
  on save too, so "de123..." doesn't fail validation just for being
  lowercase — same normalization `discount-codes.code` already used),
  `bankName` stays free text since there's no fixed format to validate a
  bank's display name against. Not a single free-text `bankDetails`
  textarea anymore, as of the 2026-07-23 e-invoicing migration's Phase 2 —
  EN16931 wants discrete PaymentMeans data. When `iban` or `bic` is set,
  the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" —
  **always**, regardless of the order's payment method. It used to say
  "Bankverbindung (für Überweisung): …",
  which read as conditional on paying by bank transfer specifically, but
  never actually was (the display was only ever gated on whether the
  field was filled in) — the misleading wording got fixed instead of
  adding the behavior it implied, since a card/PayPal customer might
  still want the seller's bank details for other reasons (e.g. a refund).
- **Summary layout — a genuinely additive chain (fixed 2026-07-23).** The
  summary card now reads Zwischensumme → Rabatt (if any) → Versand → a
  divider → Gesamt, using the order's own raw `subtotal`/`discountAmount`/
  `shippingCost`/`total` fields directly — every row above the divider
  actually sums to the number below it. A previous version showed
  "Netto"/"zzgl. X% MwSt." rows computed via `computeTaxBreakdown()`,
  which distributes discount/shipping proportionally across each tax-rate
  group *before* computing net/tax (correct for the tax math itself, since
  ancillary costs are legally apportioned across rates) — but the same
  layout **also** printed Rabatt/Versand as their own separate rows on top,
  so the visible rows never actually summed to the printed Gesamt (off by
  exactly the shipping/discount amount — caught against a real production
  order, `#EP-0006-ZDX6`'s Stornorechnung, where the gap was concrete and
  reproducible, not a rounding nit). The per-rate breakdown still exists,
  just relocated **below** Gesamt as an "enthält X% MwSt.: Y €" annotation
  (one line per distinct rate, informational — not part of the additive
  stack above it), the same "contained within, not an extra deduction"
  framing `VatBreakdown.tsx` already used on `/cart`/`/checkout` (see "VAT
  display" above). Applies to both the original invoice and its Storno/
  Gutschrift, which has its own equivalent chain (Zwischensumme → the
  discount reversal, "Rabatt (entfällt)", a positive add-back since the
  original discount no longer applies once everything's undone → Versand →
  Gesamt; a Gutschrift shows Gesamt alone, since it never reverses
  shipping/discount in the first place — see the Payload README's "How a
  Stornorechnung/Gutschrift relates to the original invoice"). When the
  order is VAT-exempt (see "VAT exemption" below), this annotation becomes
  "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead —
  "enthält 0% MwSt.: 0,00 €" would be a meaningless thing to print.
- **Netto row (added 2026-07-23)**: a dedicated "Netto" row sits between
  Gesamt and the "enthält X% MwSt." annotation on every invoice this shop
  issues (original, Storno, Gutschrift alike) — businesses read this
  directly for their own input-tax deduction instead of computing Gesamt
  minus MwSt by hand. Shown unconditionally, including on VAT-exempt
  orders (net and Gesamt happen to be the same figure there — a first
  version skipped the row in that case, corrected same day since the ask
  was for every invoice, not conditional on the tax rate).
- **Product thumbnails**: each item row shows a small product image —
  resolved from the order-confirmation data's already-available
  `imageUrl` for the checkout-time attachment, or via
  `getProductImagesByIds()` (see "VAT display" above) for the on-demand
  re-download route, since a stored order item only snapshots a numeric
  product id, not an image URL.
- **Bundle contents**: an item row for a bundle product also shows the
  small muted `bundleContents` sub-line snapshotted at order time (see
  the Payload README's "Product bundles" section).
- **Shipping address**: when `order.hasDifferentShippingAddress` is set
  (see "Optional deviating shipping address" above), a third
  "Lieferadresse" address block joins Von/An (three ~30%-width columns
  instead of two ~45%-width ones) — otherwise unchanged, two columns as
  before. `USt-IdNr.` no longer repeats in a header meta box — it already
  lives in the footer, printing it twice was redundant.
- **Buyer B2B fields**: when the order has a `companyName`/`vatId` (see
  "B2B checkout fields" below), the "An" block shows `companyName` as its
  own line above the contact person's name, and `vatId` as its own line
  below the address (labelled "USt-IdNr. …") — the buyer-side counterpart
  to the seller's own VAT ID already shown in the footer.
- **`app/lib/invoiceData.ts`** (still local to this repo — a thin
  server-only wrapper, not part of the shared package) — `generateInvoicePdf(order, seller)` /
  `generateCorrectionInvoicePdf(kind, order, seller)`, the render
  entrypoints every caller below goes through; both just call straight
  into `@einfach-produktiv/invoicing`'s `renderInvoicePdf()`/
  `renderCorrectionInvoicePdf()`. `seller` (`company-settings`
  data) is passed in rather than fetched inside these functions, so a
  caller that also needs it for something else in the same request (e.g.
  `orderEmail.ts`'s legal email footer, see "Legal footer (Anbieterkennzeichnung) on every email" below)
  fetches it once via `getSellerForInvoice()`, not twice.
- **Original invoice — called from two places, same render function:**
  `app/lib/orderEmail.ts` (checkout attachment — a PDF-generation failure
  here does **not** sink the confirmation email itself, it just sends
  without the attachment and alerts admin) and
  `app/api/account/orders/[orderNumber]/invoice/route.ts` (GET, customer's
  own order only, "Rechnung herunterladen" on
  `/konto/bestellungen/[orderNumber]`) — a re-download always matches what
  was originally emailed, since `invoiceNumber`/`invoiceIssuedAt` are
  assigned exactly once, server-side, at order creation (Payload's
  `orders.ts` `beforeChange` hook — see the Payload README) and never
  regenerated.
- **Correction invoice (Stornorechnung/Gutschrift) — same "no file
  storage" approach.** The *real* document is generated once, Payload-side,
  the moment an order reaches `cancelled`/`returned` (see the Payload
  README's "How a Stornorechnung/Gutschrift relates to the original
  invoice" section for the full legal/mechanical reasoning) and attached
  to that status email. This repo's own `/konto/bestellungen/[orderNumber]`
  "Stornorechnung/Gutschrift herunterladen" button
  (`app/api/account/orders/[orderNumber]/correction-invoice/route.ts`)
  calls the exact same `renderCorrectionInvoicePdf()` from
  `@einfach-produktiv/invoicing` the backend used to generate the
  original — not a ported approximation anymore (that used to be a
  separate, hand-duplicated copy; see the shared package's README) — so a
  re-download is now structurally guaranteed to match what was emailed,
  not just guaranteed by careful manual syncing. No PDF is ever persisted
  to disk/S3/Media: `correctionInvoiceNumber`/`correctionInvoiceIssuedAt`
  are immutable once set (Payload's `beforeChange` hook), so re-rendering
  from the order's own stored data always reproduces the identical
  document — the underlying data is already durable in Postgres, and
  deterministic regeneration needs no cleanup or storage cost, same
  reasoning already applied to the original invoice. Also has product
  thumbnails (same resolution approach as the original invoice) and, for a
  Stornorechnung specifically, an explicit "Versand" summary line — it was
  previously only folded silently into the tax-rate groups' scaled gross
  amounts, with no line stating how much of the reversed total was
  shipping. A Gutschrift never shows this line, since it never reverses
  shipping in the first place (see the reasoning below).
- **Numbering**: `invoiceNumber` and `correctionInvoiceNumber` each come
  from their own gapless counter on Payload's `number-ranges` collection
  (`invoicePrefix`/`Next`/`Padding` vs. `correctionInvoicePrefix`/`Next`/
  `Padding` — a Stornorechnung/Gutschrift used to draw from the *same*
  counter as ordinary invoices; split into its own sequence as of
  2026-07-23). Both are assigned via a single atomic `UPDATE ...
  RETURNING` against Postgres (Payload's backend `numberRange.ts`), not a
  read-then-write across two separate calls — see the Payload README's
  "Number ranges" section for why that distinction actually matters for
  §14 UStG.
- **`company-settings`** (Payload collection, structured seller data —
  name/address/`vatId`/`taxRatePercent`/`bankName`/`iban`/`bic`) is fetched via
  `getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
  way as order creation (`x-order-service-secret` header,
  `ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details)
  but does need to be reachable from this app's own server-side code, not
  just from inside Payload's admin. **Currently seeded with placeholder
  data** ("Björn Wendt", "Musterstraße 12", USt-IdNr. "DE123456789", a
  placeholder IBAN/BIC) mirroring the Impressum's own placeholder content
  — real business details need to be entered in the Payload admin before
  an invoice generated from this is legally valid. `taxRatePercent` is
  deliberately a configurable admin field, not a hardcoded `19` in the
  renderer, per an explicit decision to keep the VAT rate editable without
  a code change.
- §14 UStG line items: seller/buyer address, invoice number + date, order
  reference, per-item quantity/price, net subtotal per rate, tax rate +
  amount per rate, gross total — all on the PDF, not just the summary the
  confirmation email's HTML already shows.
- **E-invoicing (ZUGFeRD/EN16931) migration — done, live in production
  since 2026-07-23.** Every invoice generated above is actually a
  Factur-X-EN16931 hybrid PDF/A-3 (the same visual PDF, plus an embedded
  machine-readable `factur-x.xml`), for every order, not just B2B — see the
  shared package's own README for the full phased build (`@e-invoice-eu/core`,
  atomic invoice numbering, a self-hosted Mustang-CLI CI pipeline that
  validates every push to that package against real EN16931/PDF-A-3
  conformance rules).

### B2B checkout fields

Split out from the e-invoicing migration, built 2026-07-23 once that
shipped. Optional "Firma"/"USt-IdNr." fields sit right under Vorname/
Nachname in `/checkout`'s Card 1 — neither is required just because the
other is filled in (a sole proprietor might give a VAT ID with no separate
company name, and vice versa). Format-validated client- and server-side
(`app/lib/vatId.ts`'s `isValidVatId()`/`normalizeVatId()`, same EU-format
regex as `company-settings.vatId` on the backend), persisted through
`checkoutDraft.ts` like every other Card 1 field, and saved as a
`Customers` profile default (`/konto/profil`) that prefills future
checkouts — `Orders` keeps its own independent snapshot regardless, same
"a later profile edit must never rewrite what an order actually said"
reasoning as every other snapshot field. Shown on the invoice PDF's "An"
block (see "Invoice PDFs" above) and threaded through both invoice
download routes and the correction-invoice email.

### VAT exemption (innergemeinschaftliche Lieferung)

Built 2026-07-23. A cross-border EU B2B sale — Österreich is the
eligible destination (`isExemptionEligibleCountry()` in
`app/lib/vatExemption.ts`, hardcoded, deliberately **not** read from the
Payload-configurable `shipping-countries` list above — eligibility is a
legal decision, not a shipping-logistics one, so an admin adding a new
destination country can't accidentally also grant it a VAT exemption) —
to a buyer whose VAT ID a live lookup against the EU's public VIES
service actually confirms is registered gets zero-rated per §4 Nr. 1b
UStG. Deliberately *not* based on format-validity alone: an unverified
VAT ID zero-rating an invoice is a real compliance risk (if it later
turns out unregistered, the seller retroactively owes the VAT itself).

- **`app/lib/vies.ts`** — calls the European Commission's public VIES REST
  API (`POST .../check-vat-number`, confirmed live 2026-07-23) directly,
  server-side only.
- **`app/lib/vatExemption.ts`** — `destinationCountry()` picks the actual
  place the goods ship to (the shipping-address override's country when
  set, billing country otherwise — the exemption depends on where the
  goods physically move, not necessarily the invoice address);
  `computeExemptTotals()` de-grosses every item's price and the shipping
  cost from their normal VAT-inclusive catalog figures to net, since the
  whole point of the exemption is that the buyer pays less, not that this
  shop quietly keeps the VAT portion as extra margin.
- **VAT-ID *validity* and the exemption *decision* are two separate
  questions (fixed 2026-07-23)** — a first version only ever called VIES
  when the destination already qualified for the exemption (Österreich),
  so a garbage VAT ID on a domestic order (e.g. `"ED123456789"` — not even
  a real country code) sailed through with just a format check, and a
  genuinely valid German/Swiss VAT ID got no confirmation either. Now
  `app/api/checkout/validate-vat/route.ts`/`app/api/checkout/route.ts`
  check any format-valid VAT ID against VIES regardless of destination
  (data quality — worth knowing whether it's real at all, same reasoning
  as `company-settings.vatId`'s own check below) — the exemption itself
  still only applies when the destination is *also* Österreich. A
  validated German VAT ID never zero-rates a domestic sale, no matter how
  real it is.
- **Checkout UX**: the USt-IdNr. field's blur gives instant feedback for
  any country — "✓ USt-IdNr. bestätigt" (plus "— Lieferung wird steuerfrei
  berechnet." only when the destination actually qualifies) flips the
  sidebar total to the exempt (de-grossed) figures live, as a preview.
  `app/api/checkout/route.ts` re-runs the exact same VIES check server-side
  at submit time regardless, as the actual source of truth — the
  client-side result is never trusted. **VIES being unreachable fails
  closed on the exemption**: normal VAT applies, never a guessed exemption
  (contrast the Payload backend's own `company-settings.vatId` VIES check,
  which fails open, since that one only needs to catch an admin's
  data-entry typo, not decide a tax rate). On an unconfirmed VAT ID, the
  field re-focuses so the customer's attention returns there — it no
  longer also selects the whole existing value (dropped 2026-07-23; a
  stray keystroke while just glancing at the error shouldn't wipe out
  what was already typed). This same "re-focus the failing field"
  behavior now applies generically to *any* checkout field that fails
  its own blur validation, not just this one — see the bullet below.
- **Every other checkout field is now blur-validated too** (fixed
  2026-07-23, alongside this feature) — inline red error text appears the
  moment a field loses focus (required fields, email format, PLZ digit
  count per country, Packstation/Postnummer digit count), not only when
  the browser's native `pattern`/`required` validation kicks in at submit.
  The native attributes stay in place as a fallback for any field somehow
  never blurred (e.g. autofill). `setFieldError(name, message, refocusEl?)`
  (`CheckoutContent.tsx`) takes an optional element to refocus whenever the
  message is non-empty — every field's own `onBlur` passes `e.target`, so
  a field that fails validation gets focus put right back on it
  generically, not just the USt-IdNr. special case above.
- **Persistence**: `Orders.vatExempt`/`vatIdValidatedAt` (Payload backend)
  record the outcome, decided once server-side, never editable in the
  admin. `vatIdValidatedAt` is set for *any* VIES-confirmed VAT ID
  (data-quality audit trail), independently of whether `vatExempt` is also
  true — see the Payload README's "B2B checkout & VAT exemption" section
  for the full field/audit-trail reasoning and the invoice PDF/EN16931 XML
  side of this feature.
- **Every fixed-length numeric field now hard-caps input length too**
  (fixed 2026-07-23) — PLZ (`maxLength` = the selected country's own digit
  count), USt-IdNr. (`14`), Packstationnummer (`3`)/Postnummer (`10`, on
  the backend) all already had `pattern`/`validate` format checks, but
  nothing stopped the browser from accepting more characters than could
  ever pass. Same "as simple/state-of-the-art as possible" input-quality
  pass as the blur-validation fix above.
- **`/bestellbestaetigung`** mirrors the same exempt-totals branch from the
  persisted `OrderSnapshot.vatExempt` flag (it otherwise re-derives totals
  live from the current catalog, which would show the wrong, VAT-inclusive
  figures for an exempt order).

### Kleinunternehmerregelung (§19 UStG)

Built 2026-07-24. `company-settings.kleinunternehmer` — a standing
per-tenant setting (Payload backend), not a per-order decision like the
VAT exemption above — when on, this tenant never charges VAT on anything,
domestic or cross-border. See the Payload README's own
"Kleinunternehmerregelung" section for the field/collection side; this one
covers what changed here.

- **`api/checkout/route.ts`** forces every item's `taxRatePercent` to `0`
  when `getCompanySettings().kleinunternehmer` is on — deliberately
  **without** de-grossing `unitPrice` the way `vatExempt` above does
  (that exemption zero-rates what would otherwise be a positive-rate
  charge, so de-grossing means the buyer pays less; a Kleinunternehmer
  never charged VAT on the sale to begin with, so the catalog gross price
  already *is* the actual net charge — confirmed with the user as the
  intended business decision, not an engineering default). The VIES
  lookup/`isExemptionEligibleCountry()` check is skipped entirely in this
  branch too — there's no VAT for the intra-community rule to exempt
  either. Snapshotted onto the new order as `Orders.kleinunternehmer`
  (mirrors `vatExempt`'s own snapshot reasoning — see the Payload README).
- **Storefront "inkl. X% MwSt." hints** — four spots read the *live*
  setting (`getKleinunternehmer()` in `app/lib/payload.ts`, same ISR-cached
  60s freshness as `getDefaultTaxRatePercent()`) and drop the MwSt. clause
  entirely when it's on, since there's no order yet at that point to
  snapshot from: `ProductGrid.tsx` (shop grid), `RelatedProducts.tsx`
  (cart's upsell row), `Pricing.tsx`/`TodoKartenHero.tsx` (ToDo-Karten
  landing page), `ProductSpotlight.tsx` (homepage). `Pricing.tsx`/
  `ProductSpotlight.tsx` keep "zzgl. Versand" on its own when the MwSt.
  clause drops; the other two had no such trailing clause to preserve.
- **Every already-placed-order display reads the persisted snapshot
  instead** — `OrderSnapshot.kleinunternehmer` (`app/lib/order.ts`,
  written into `sessionStorage` at checkout, read by
  `BestellbestaetigungContent.tsx`) and `CustomerOrderDetail.
  kleinunternehmer` (`app/lib/customerAuth.ts`, read by
  `/konto/bestellungen/[orderNumber]`) — never the live company-settings
  value, for the identical "don't retroactively rewrite an already-issued
  invoice's tax treatment" reason `vatExempt` already established. Both
  pages replace the per-item "inkl. X% MwSt." hint and the `VatBreakdown`
  summary with "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." —
  taking precedence over the `vatExempt` note wherever both would
  otherwise apply. `CheckoutContent.tsx`'s live VIES-exemption *preview*
  is also gated off (`!kleinunternehmer && ...`) so a Kleinunternehmer
  tenant never shows a misleading "wird steuerfrei berechnet" preview for
  VAT that was never going to be charged either way.
- **On-demand invoice/Stornorechnung/Gutschrift downloads**
  (`api/account/orders/[orderNumber]/invoice/route.ts` and its
  `correction-invoice` sibling) thread `order.kleinunternehmer` through to
  `@einfach-produktiv/invoicing`'s renderers the same way they already
  thread `vatExempt`.
- **`app/company-settings-preview`'s Live Preview** merges the live-edited
  `kleinunternehmer` checkbox onto the fixed `SAMPLE_INVOICE_ORDER` before
  rendering (`kleinunternehmer` lives on `InvoiceOrder`, not
  `InvoiceSeller` — see the invoicing package's own README on why), so an
  admin sees the §19 UStG notice appear/disappear live as they toggle the
  field, without this preview needing its own separate mechanism.

### Company Settings & Live Preview

`company-settings` has a Live Preview button too, like `email-templates`
— but instead of an HTML page, it's a **live, in-browser rendered PDF**:
opening the document in the Payload admin shows the actual invoice layout
updating as the admin edits `sellerName`/address/`taxRatePercent`/
`bankName`/`iban`/`bic`, no save required.

- **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`**
  — same entrypoint pattern as `/email-preview/[type]` (Draft Mode via
  `app/api/preview/route.ts`), but no `[type]` segment — there's only one
  kind of document here, unlike the 6 email types. **Actually gated on
  `draftMode().isEnabled`** (calls `notFound()` otherwise), unlike
  `/email-preview` — this data includes a real bank IBAN/address once
  filled in, not just marketing email copy, so it must not render for an
  unauthenticated visitor who happens to find the URL.
- **`@react-pdf/renderer`'s `<PDFViewer>`** (not `renderToBuffer()`) is
  what makes this a *live* preview rather than a static download — it's a
  browser-only component that renders a `Document` straight into an
  embedded PDF viewer `<iframe>`, re-rendering whenever its props change.
  Paired with `useLivePreview()`'s live `data` (same postMessage mechanism
  as the email templates preview), editing a field in the admin re-renders
  the actual PDF in real time — no server round-trip per keystroke.
  Dynamically imported with `{ ssr: false }` (`next/dynamic`) since it
  touches the DOM directly; the HTML-string email previews elsewhere don't
  need that since they're just `dangerouslySetInnerHTML`.
- Renders `InvoiceDocument` (exported from `@einfach-produktiv/invoicing`
  specifically for this — everywhere else only the async
  `renderInvoicePdf()` buffer-generator is used) against a fixed
  `SAMPLE_INVOICE_ORDER` (also exported from that same package) — same "no
  real document to preview against generically" reasoning as
  `email-templates`' own `SAMPLE_ORDER`.
- No draft/published distinction here, unlike `email-templates`:
  `company-settings` has no content-versioning concept, it's just the
  current row — the page's initial (pre-postMessage) fetch is the same
  live data `getCompanySettings()` always returns.

## Newsletter signup & Brevo sync

Built 2026-07-23. Four separate signup entry points across the site —
the shared `Newsletter` panel (`app/components/Newsletter.tsx`, reused on
Home and `/newsletter`), `NewsletterModal.tsx` (the Navbar's "Newsletter"
CTA), `/newsletter`'s own inline hero form
(`app/newsletter/components/WeeklyImpulsesHero.tsx`), and `/challenge`'s
`EmailCapture` (`app/challenge/components/EmailCapture.tsx`) — plus
checkout's existing `newsletterOptIn` checkbox, all sync to Brevo's
contact list. **Three of the four standalone forms were completely
non-functional before this** (static markup, no `onSubmit` at all —
discovered while wiring this up, not a regression) and the fourth
(`NewsletterModal`) plus checkout's checkbox captured data that just sat
unsynced.

- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
  `upsertNewsletterContact(email, source)` calls Brevo's
  **double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation`
  (switched 2026-07-25 from the plain `POST /v3/contacts` single-opt-in
  upsert this originally shipped with) — this only ever *requests* a
  subscription; Brevo sends a confirmation email (the template at
  `BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in
  template in Brevo's own UI) and only actually adds the contact to
  `BREVO_LIST_ID` once they click through. `source` (`"checkout"` |
  `"newsletter-page"` | `"newsletter-modal"` | `"newsletter-hero"` |
  `"challenge"`) is stored as the contact's `OPT_IN_SOURCE` attribute for
  segmentation — that attribute has to already exist on the Brevo account
  (`POST /v3/contacts/attributes/normal/OPT_IN_SOURCE`) or Brevo silently
  drops it on every request (no error at all, just never stored) rather
  than rejecting the request. `redirectionUrl` (where Brevo sends the
  contact after they click confirm) defaults to `/newsletter-confirmed`
  via `BREVO_DOI_REDIRECT_URL` — a static confirmation page
  (`app/newsletter-confirmed/page.tsx`), same visual language as
  `/bestellbestaetigung` (brand-tinted circular checkmark, serif display
  heading, thin brand divider). No query params to read — Brevo's
  redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
  which polls actual payment status.
- **Already-subscribed detection (2026-07-25)** — `doubleOptinConfirmation`
  itself gives no way to tell a brand-new signup apart from an
  already-confirmed contact re-submitting the form (verified directly:
  calling it twice for the same confirmed contact returns the identical
  `201` both times, just silently resends the confirmation mail). So
  `upsertNewsletterContact()` checks first via `GET /v3/contacts/{email}`
  — a contact's `listIds` on Brevo is only populated once double opt-in
  actually confirms, never for a merely-requested one, so its presence is
  a reliable signal. If already subscribed: skips the resend entirely and
  returns `{ ok: true, alreadySubscribed: true }` instead. The check fails
  open (any error → proceed to the normal signup flow) — it's a UX
  nicety, never a reason to block a real signup. **Routed through the
  existing error state, not a success variant** — per explicit feedback,
  swapping the whole form out for a bare message (the real-success
  treatment) felt wrong for "you're already signed up, nothing to do"; the
  form stays visible with a small red note below it instead, exactly like
  every other inline validation error (`useNewsletterSignup.ts` sets
  `status: "error"`, `error: "Diese E-Mail-Adresse ist schon für unseren
  Newsletter angemeldet."` — no new UI needed in any of the 4 forms, they
  already render `{status === "error" && <p className="text-red-600
  ...">{error}</p>}`).
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
  state + on-blur validation + refocus-on-invalid-submit behind all four
  forms (same "state of the art, simple" input-quality bar as checkout's
  own fields). Each form keeps its own markup/visual style (`Newsletter`'s
  panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
  the logic is shared, not a one-size-fits-all component. The real-success
  message text also now lives here (`successMessage`) instead of
  hardcoded 4 times per form.
- **A misconfigured `BREVO_LIST_ID` in Coolify (`2` instead of `5`) broke
  every newsletter signup in production for a stretch of time** —
  discovered and fixed 2026-07-25 while testing the already-subscribed
  feature above. The generic customer-facing error message ("Anmeldung ist
  fehlgeschlagen...") gave no hint why; `upsertNewsletterContact()`'s real
  `reason` was silently discarded by `/api/newsletter/subscribe/route.ts`
  before this, now `console.error`'d server-side (message still stays
  generic to the customer — never leak Brevo's internal error text, just
  no longer *undiagnosable*).
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
  the single plain-email-format check shared by every newsletter form
  *and* checkout's own email field (previously duplicated between
  `CheckoutContent.tsx` and the subscribe route).
- **`app/api/newsletter/subscribe/route.ts`** — validates
  email-format + consent server-side too (never trusts the client alone),
  then calls `upsertNewsletterContact()`.
- Checkout's sync (`app/api/checkout/route.ts`) is fire-and-forget
  alongside the order-confirmation email — a failed marketing sync must
  never fail checkout, and isn't worth a critical alert either (nothing
  customer-facing depends on it).
- **This app never sends marketing/campaign mail itself** — only
  transactional (order confirmation, password reset, status updates).
  Whatever automation Brevo has configured on the list (a "Welcome Flow"
  etc.) runs entirely on Brevo's own side once a contact lands there;
  Brevo's Automation workflows aren't exposed via their public REST API
  at all, so that piece can only be built/inspected in Brevo's own UI, not
  from this codebase.
- Needs `BREVO_API_KEY`/`BREVO_LIST_ID` set in the deployment environment
  — confirmed live end-to-end 2026-07-23. As of the double-opt-in switch,
  also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
  signup silently no-ops without it) and optionally
  `BREVO_DOI_REDIRECT_URL`.
- **The already-subscribed error now clears on the next interaction**
  (2026-07-25), matching standard form-validation behavior instead of
  sitting there until the next submit — `useNewsletterSignup.ts`'s
  `handleEmailChange`/new `handleConsentChange` both call a shared
  `clearSubmitError()` that resets `status`/`error` back to idle. This
  replaced the hook's previously-exposed raw `setConsent` with
  `handleConsentChange` in its return value — all 4 consuming forms
  updated to match.
- **`NewsletterModal.tsx`'s photo no longer resizes when the error
  appears** (2026-07-25) — the modal's left photo stretches
  (`items-stretch`, `md:aspect-auto`) to match the right column's height,
  so the emailError/already-subscribed messages growing that column used
  to visibly grow the photo along with it. Both messages are now always
  rendered with a reserved `min-h-[1.05rem]` instead of conditionally
  mounted, so toggling them no longer changes the column's height at all.

## 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). There's no persistent
"already a customer? log in" prompt — that was gendered ("Schon Kundin?")
and shown to every logged-out visitor regardless of relevance. Instead,
the email field's `onBlur` calls `/api/account/check-email`
(`checkEmailExists()` in `customerAuth.ts`, service-secret authenticated —
Customers isn't public-read) and only *then* swaps Card 1's own
"Passwort (für dein neues Konto)" field out for an inline login prompt
(password field + "Einloggen" button), gender-neutral copy, rendered
right under the same email field the shopper just typed into rather than
asking for it a second time in a separate field. `handleSubmit`'s own
`emailExists` handling (see "Checkout registration collisions" below) is
the fallback for the case this check was skipped or raced.

**Fixed 2026-07-24** — this login prompt used to render in a completely
separate block above the whole form (before `<form>` even opens), which on
a shopper who'd already scrolled down to reach Card 1's email field meant
the prompt popped in off-screen, above their current scroll position, with
no auto-scroll wired up for this common blur-triggered path (only the
submit-time fallback had one). Moved inline into Card 1 itself instead —
no scrolling needed in the common case since it now appears exactly where
the shopper is already looking. The submit-time fallback (see "Checkout
registration collisions" below) still scrolls it into view, now via a
`useEffect` watching `showLogin` rather than a synchronous call at the
`setShowLogin(true)` site — the prompt is conditionally rendered, so its
ref isn't attached to anything yet at that exact synchronous point.

- **`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),
  `orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
  cancellation & returns" below), and `orders/[orderNumber]/invoice`
  (GET — invoice PDF download, see "Invoice PDFs" 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`, plus
  companyName/VAT-ID and a VAT-exemption note when the order has them —
  added 2026-07-23, `CustomerOrderDetail` already carried these fields but
  the page never rendered them). `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.
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
  here** — that's a `pending_payment` order whose payment failed or timed
  out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
  a real Storno (which is always of an already-`received`, already-
  invoiced order, so it always has an `invoiceNumber`). From the
  customer's point of view a payment that never went through was never
  really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
  (`app/lib/customerAuth.ts`) filter these out by default — the row still
  exists in Payload for admin/audit purposes (shown there as "Zahlung
  fehlgeschlagen", see the backend's own README), just not surfaced to
  the customer. `getCustomerOrderDetail` takes this as an **optional**
  4th param, defaulting `false` — `/api/checkout/status/route.ts`'s
  post-payment polling deliberately calls it unfiltered, since that flow
  needs to keep seeing exactly this order (to show "Zahlung
  fehlgeschlagen, bitte erneut versuchen") for the one case this filter
  would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
  opts out (`false`) — a legal completeness export can't silently drop
  rows.
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
  header itself — not duplicated inside the mobile fullscreen menu, see
  "Mobile navigation" below) 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. Re-fetches on an
  `ep-auth-changed` `window` event (`app/lib/auth.ts`'s `dispatchAuthChanged()`,
  called by every login/logout/checkout-registration call site) — the icon
  otherwise never noticed a login/logout until a hard reload, since
  `router.refresh()` only re-runs Server Components, not an
  already-mounted Client Component's effects, and this Navbar lives in the
  root layout and never unmounts across navigations. 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.

### Mobile navigation

Below `lg` (1024px), the hamburger opens a **fullscreen** panel
(`Navbar.tsx`, `motion.div` from the `motion/react` package already used
elsewhere in this app for `NewsletterModal`/`VersandModal`) — not an
in-flow accordion pushed under the header like before. A circular
`clip-path` reveal (`circle(0vmax at 100% 0%)` → `circle(150vmax at 100%
0%)`, `vmax` rather than `%` so full coverage holds regardless of aspect
ratio) expands from the hamburger's own top-right corner, sweeping toward
the opposite corner last. Nav links fade/rise in with a per-item stagger
once the reveal has visibly opened up. The panel is a **sibling** of
`<header>`, not a child — `mobileOpen` gives the header its own
`backdrop-blur`, which would make it a new CSS containing block for any
`position: fixed` descendant and break the panel's fixed-to-viewport
positioning (same class of bug documented on `NewsletterModal`). No
login/account CTA inside the panel — that's reachable via the account icon
in the header itself, which stays visible above the panel throughout.
- **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
  `showLogin` on (same inline prompt described above — reuses Card 1's
  own email field, nothing to pre-fill) and scrolling it into view via a
  `useEffect`, instead of leaving the customer stuck with an error and no
  obvious next step. `handleLogin()` itself posts the live `email` field
  value, not a separate `loginEmail` state — there's only ever one email
  input on this form now.
- **`/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.
  Its "Land" `<select>` used to hardcode Deutschland/Österreich/Schweiz
  independently of `/checkout`'s own country list — **fixed 2026-07-24**:
  `ProfileForm.tsx` now takes a `shippingCountries` prop (`page.tsx` fetches
  `getShippingCountries()`, same Payload-configurable list `/checkout`
  already reads), so a country added/removed in the admin reaches both
  places instead of just one.
- **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

All 9 transactional emails (order confirmation, password reset, and the 7
status-change types below — the original 4 plus `order-tracking-added`/
`order-tracking-corrected`/`order-delivered`, added 2026-07-25) 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`).

**Live Preview only reliably updates when popped out into its own
browser tab/window, not in the embedded admin panel.** Root cause: the
preview goes through `buildPreviewUrl()` on the Payload side, which hits
`/api/preview` on this frontend's own origin to enable Next.js Draft
Mode via a cookie — but admin (`payload.mk360.de`) and frontend
(`einfach-produktiv.mk360.de`) are different origins, so from inside the
admin's embedded `<iframe>` that's a cross-site/third-party context.
Modern browsers (Safari by default, Chrome/Firefox increasingly)
block or partition cookies set inside a cross-site iframe regardless of
which domain actually issued them, so the Draft Mode cookie doesn't
reliably persist there. Once popped into its own window it's a top-level
navigation, not a third-party context, so the cookie sets normally and
everything works. Affects every Live-Preview-enabled collection in this
system (Posts/LegalPages/Testimonials too), not just email templates —
a structural consequence of running admin and frontend on separate
domains, not something fixable at the collection-config level. A real
fix would mean serving both under one domain (reverse proxy path) rather
than two subdomains.

- **`app/lib/emailTemplates.ts`** — pure string-building functions
  (`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
  `renderOrderStatusHtml()`), 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, the only one of the 6 actually sent
  from this repo (password-reset and all 4 status-change types are sent by
  Payload itself, using its own inline templates — see that repo's README
  for why — so their Live Previews approximate rather than pixel-match).
  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.
- **`active` toggle (2026-07-25)** — each row now has an `active`
  checkbox (backend `EmailTemplates.ts`); off suppresses the send
  entirely, checked here for `order-confirmation`
  (`orderEmail.ts`'s `sendOrderConfirmationEmail()`) before the hardcoded
  default-wording fallback ever applies. See the backend repo's own
  README ("Email templates: active/inactive toggle") for the full
  picture, including the `password-reset` exception (Payload's core
  `forgotPassword` operation has no hook to actually cancel that send).
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
  defaults for all 9 rows — deliberately on-brand and a little playful
  ("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles
  klar." / "Alles erledigt.", not generic transactional-email
  boilerplate), matching this site's voice elsewhere (see e.g. the
  testimonial copy). Two intentional exceptions to that voice: the
  email-verification mail (Payload-side, plain functional copy — see that
  README) and the Stornorechnung/Gutschrift PDFs (formal legal documents,
  no brand voice by design). Editable in the admin afterward regardless.
  `sendOrderConfirmationEmail()` also has a hardcoded fallback for the
  rare case a fresh install's order arrives before that seed has run.
- **`emailShell()`'s visual design deliberately echoes `/bestellbestaetigung`**
  (the on-screen order confirmation page) rather than reading as a generic
  transactional email: same warm cream background/brand color as
  `globals.css`'s `--color-*` tokens (hardcoded here as literal hex — email
  clients don't resolve `var()` either), a circular brand-tinted icon
  (✓ for order-confirmation, ✉ for password-reset) echoing that page's own
  success-icon treatment, a thin brand-colored divider under the heading,
  and a Georgia/serif heading font as the closest reliably-available
  approximation of the site's Playfair Display (most email clients strip
  `@font-face`/external font requests, so an actual web font isn't an
  option here). Not shared code with the React page — this is plain
  inline-styled HTML built for email-client compatibility (nested
  `<table>`s, no flexbox) — just matched by eye.
- **Footer carries a full legal Anbieterkennzeichnung, not just a brand
  line.** `emailShell()` takes a `footerLines: string[]` array built by
  `buildLegalFooterLines(seller)` — `sellerName`, `sellerStreet`,
  `sellerZip`/`sellerCity` (+ `sellerCountry` if not Germany), `E-Mail:
  sellerEmail`, and `USt-IdNr.: vatId` when set — the same admin-editable
  `company-settings` fields the invoice PDFs already use, rather than a
  literal `"einfach produktiv · admin@mk360.de"` string. `orderEmail.ts`
  and `alertAdmin.ts` (both the resend-verification mail and the plain-text
  critical-alert mail) all fetch `company-settings` once
  (`getSellerForInvoice()`) and pass the `seller` object straight into the
  render functions, which call `buildLegalFooterLines()` themselves — one
  place composes the footer, not each call site. The Payload-side sends
  (password-reset, the 4 status-change emails, the *initial* verification
  email) get the equivalent treatment via that repo's own
  `src/lib/sellerInfo.ts`'s `buildLegalFooterLines()`/`getSellerFooterLines()`
  — see that repo's README for its own copy of this section. Live Preview
  passes `seller: null`, which falls back to `DEFAULT_LEGAL_FOOTER_LINES` (a
  placeholder Anbieterkennzeichnung) since there's no real order/tenant
  context there to fetch against. Note `company-settings` currently has no
  Handelsregister court/number or Geschäftsführer field — fine for a sole
  proprietorship, but would need adding if the business becomes a
  registered legal form (GmbH etc.), see that collection's own field list
  above.
- **`From` display name is dynamic (`seller.sellerName`), the address
  itself stays `admin@mk360.de`.** `Reply-To` is set to `seller.sellerEmail`
  so a customer's reply actually reaches the seller regardless of the From
  address. The address isn't also switched to `sellerEmail` because that
  domain isn't confirmed SPF-authorized on the Hostinger account backing
  `admin@mk360.de` yet — doing so without that confirmation risks
  order-confirmation/verification mail landing in spam or bouncing outright.
  Both `orderEmail.ts` and `alertAdmin.ts`'s `sendVerificationEmail` set
  this the same way; `sendCriticalAlert` (internal, admin@mk360.de to
  itself) doesn't need it.

### 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()`.

Requesting a return opens an inline form (`OrderActionButton.tsx`), not
just a confirm dialog — **partial returns are supported**: a quantity
input per order line (0 up to that line's ordered quantity) plus a
required reason textarea. At least one line must have a nonzero quantity
to submit. Cancel stays a plain `window.confirm()` — it's a lower-stakes,
whole-order-only action (before shipping, often just a change of mind),
no quantity picker or reason needed there.

The route (`/api/account/orders/[orderNumber]` PATCH) reconstructs the
order's *full* `items` array before sending it to Payload — only the
requested lines' `returnQuantity` differs from what's already stored,
every other field (product/price/tax rate/etc.) is passed through
unchanged. This isn't optional: Payload's array field expects every
required sub-field present on each row, and the field-lock hook (see
below) specifically checks that only `returnQuantity` changed — a sparse
`{ returnQuantity: 2 }`-only patch would fail both.

**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 rejects a customer-authenticated
update unless the change is limited to `status` (plus `returnReason` and
each item's `returnQuantity`, bounded 0..quantity, alongside a
`return_requested` transition), via an allowed transition. See the
Payload README's own writeup for the full detail — including
`orderUpdateValidation.ts`, where this logic now lives as a unit-tested
pure function.

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.

Reaching `status: 'cancelled'` or `status: 'returned'` also auto-generates
a **Stornorechnung**/**Gutschrift** correction-invoice PDF, attached to
that status's customer email (Payload-side, see the Payload README's "How
a Stornorechnung/Gutschrift relates to the original invoice" section —
this is not frontend code). Stornorechnung is always a full reversal
(cancellation is always pre-shipping, whole order, shipping included).
Gutschrift reflects only the returned quantities — full or partial —
**excludes shipping** (already delivered by the time a return is
possible) and **never reprorates the original discount** (confirmed
policy, not a default: the discount stays with whatever's kept). Either
way, this is a document only, not a money movement: an actual refund
still has to happen manually, since no payment provider exists yet to
capture or reverse a real charge.

### Status-change emails

Four `orders.status` transitions trigger a customer email — `shipped`,
`cancelled`, `return_requested`, `returned` (deliberately not `delivered`,
redundant with the carrier's own notification; not `processing`/`received`,
not customer-actionable). **Sent entirely from Payload**, not this repo —
`orders.ts`'s `afterChange` hook there compares `doc.status` to
`previousDoc.status` and fires regardless of who made the change (an admin
setting `shipped`/`returned` in the Payload admin, or the customer's own
self-service cancel/return-request above land on the exact same hook). See
the Payload README's `orders.ts` section for the actual send logic,
including the Stornorechnung/Gutschrift attachment on cancelled/returned.

This repo's only involvement is the **Live Preview approximation** — same
established gap as password-reset (see below): `renderOrderStatusHtml()`
in `app/lib/emailTemplates.ts` and the corresponding entries in
`/email-preview/[type]`'s `VALID_TYPES` exist purely so an admin editing
`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`
in the `email-templates` collection sees a reasonable preview — the actual
sent HTML is Payload's own `src/lib/emailShell.ts` render, not this file's.

### 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).

`/shop` itself also has its own Kuma HTTP monitor ("einfach-produktiv Shop
(Produkte, Varianten, Lagerbestand)", same "Content & API" group) — added
once the shop grid started doing real work at render time (`fullyOutOfStock`
across a product's variants, `effectivePrice()`), not just listing static
content; `/api/health` alone only proves Payload is reachable, not that this
specific page still renders. The Payload jobs queue's own failure monitor
(`/api/health/jobs`, `hasError: true` in the last 24h) already covers all
five scheduled jobs generically by task-agnostic query — the four added this
session (low-stock digest, stale-unverified-accounts report, weekly revenue
report, expired-discount-code cleanup) needed no monitor changes of their
own; see the Payload README's "Jobs Queue" section.

## Mobile responsive pass (2026-07-24)

A full-site pass fixing concrete Mobile-width bugs across the Home Hero,
Navbar, Newsletter, cart, `/challenge`, `/todo-cards`, blog detail, and the
legal pages — found by testing on a real phone rather than just resizing a
desktop browser to 768px. The detailed technical lessons (aspect-ratio
distortion from mixing fluid/fixed sizing, `preserveAspectRatio="none"` SVGs,
`whileInView` failing to trigger on short/narrow viewports, etc.) are written
up as dated Gotchas in the `figma-to-nextjs` skill
(`~/.claude/skills/figma-to-nextjs/SKILL.md`) rather than duplicated here —
that's now the reference for "why" on any of this. Two changes are worth
calling out specifically since they add new shared components:

- **`app/components/StepArrow.tsx`** — a small inline-SVG connector arrow,
  replacing `/icon-arrow-connector.svg` across all three "how it works" step
  sections (`/challenge`, `/todo-cards`, `/newsletter`). The old asset's
  color lived in a CSS custom property scoped to the SVG file itself, so it
  could never actually become the brand orange used everywhere else once
  loaded via `next/image` — converting it to a real component was the only
  fix, and having one shared component means a future color/shape/size
  tweak is one edit instead of three.
- **`app/components/SectionTOC.tsx`'s `MobileSectionTOC`** — the
  Impressum/Datenschutz/AGB/Widerruf/Versand table-of-contents sidebar was
  `hidden` entirely below `lg:` (a 360px sidebar genuinely doesn't fit next
  to a readable content column below that), which meant Mobile/Tablet had no
  on-page navigation aid at all on these often-long pages — exactly where
  scanning a long legal document by scrolling is hardest. `MobileSectionTOC`
  is a `<details>/<summary>` accordion (no extra JS state needed) sharing the
  same scroll-spy "active section" logic (extracted into a `useActiveSection`
  hook) as the desktop sidebar nav, rendered as its own element right after
  each page's heading — not nested inside the sidebar's `hidden lg:...`
  wrapper, which would hide it too regardless of its own `lg:hidden` class.

## Custom post content blocks, and SEO settings (2026-07-24)

`app/components/RichText.tsx` switched from a small hand-rolled Lexical
JSON→JSX walker to Payload's official `@payloadcms/richtext-lexical/react`
renderer + custom `JSXConverters` — needed once the Payload repo's
`Posts.content` gained custom Lexical Blocks (`Bild`/`Bildergalerie`/`Video`/
`Zitat`, see the Payload README's own section on this), which the old
hand-rolled `switch` had no case for at all. Kept the exact same exported
call signature (`RichText({ content, quoteLabel })`), so `LiveRichText.tsx`
and `app/blog/[slug]/components/LivePostContent.tsx` needed zero changes.
`extractHeadings()`/`headingId()` (used by `SectionTOC` on legal pages) stay
an independent, minimal walk over the raw JSON, unrelated to the new
renderer — legal pages don't use Blocks.

Block converters follow this project's established "CMS-sourced image"
convention (`relative` + `aspect-[…]` + `next/image fill` + `object-cover`,
see the blog thumbnail treatment) — a gallery block renders as a
`grid-cols-2` grid, a video block resolves a YouTube/Vimeo URL into an
`<iframe>` embed. The converters object is built fresh per `RichText()` call
(a factory closing over that call's own `quoteLabel`), not a module-level
constant — Server Components can render multiple posts concurrently in the
same process, so a shared mutable variable would be a real race condition.

**SEO settings** — new `getSeoSettings()` in `app/lib/payload.ts` (same
ISR-cached, public-safe-subset-of-CompanySettings pattern as
`getKleinunternehmer()`), with a hardcoded fallback equal to what used to be
directly in `app/layout.tsx`. `app/layout.tsx`'s `metadata` export became
`generateMetadata()` reading it. `PostDetail`/`mapPayloadPost`/
`getPostBySlug()` gained `seoTitle`/`seoDescription`/`seoImage`, each
falling back to the post's own `title`/`excerpt`/`thumbnail` when empty;
`app/blog/[slug]/page.tsx`'s `generateMetadata()` uses them (now also
setting a `twitter` block, previously missing). A few smaller gaps found
while auditing every page's metadata were fixed in the same pass: 3
`konto/*` pages were missing a `description`, `/konto/bestellungen/
[orderNumber]` had a static "Bestelldetails" title despite being a dynamic
route (now uses the real order number), and `/shop`/`/blog` were missing an
Open Graph image.

## Tablet responsive fixes (2026-07-24)

A follow-up to the Mobile pass above — testing at real Tablet widths
(768-1023px) surfaced a few spots where the Mobile-focused fixes had left
Tablet worse off than before, or where a component's own structural
breakpoint no longer needed to be as conservative as originally set:

- **`Hero.tsx` reverted from `lg:` back to `md:` as its structural
  breakpoint.** It was moved to `lg:` earlier (see the figma-to-nextjs
  skill's Gotcha #5) because at `md:col-span-5` the text column was only
  ~283px at 768px and the then-full-size CTA/subtitle text made the whole
  row wrap to 3 cramped lines. The smaller fixed CTA/subtitle/icon sizes
  added during the Mobile pass (for true phone widths) are well under half
  that column's width, so the original wrapping problem doesn't recur —
  moving back to `md:` means Tablet gets the real 5/7 grid (image beside
  text) again instead of a phone-style stacked layout. Every `lg:`-gated
  size override in that file moved to `md:` to match.
- **`Tools.tsx`'s card icon** — full 56px size pushed from `md:` to `lg:`;
  at Tablet the title/description text is still close to its own fluid
  floor, and the full-size icon read as too big next to it.
- **`About.tsx`'s text/photo columns** — the Desktop overlap layout
  (`flex-[1.4_0_0]` on the photo, `-ml-48`, gradient) kicked in from `md:`
  already, giving the text column (fixed against the smaller 1:1.4 share)
  too little room for its fixed-width statement + quote/bio row at Tablet.
  The ratio is swapped at Tablet (text gets the bigger 1.4 share, photo the
  smaller one, no overlap) and reverts to the original ratio + overlap only
  from `lg:` up, where it was designed for.
- **`Newsletter.tsx`'s input+submit row** and **`Footer.tsx`'s logo/handle/
  legal-links row** — both went side-by-side at `md:`, but the columns
  around them (the newsletter card's fixed-width copy column; the footer's
  3 groups sharing one `justify-between` row) didn't leave enough width at
  768px. Both pushed from `md:flex-row` to `lg:flex-row` — stacked through
  the whole Tablet range, side by side again once there's real room.

## Structured data (schema.org, 2026-07-25)

JSON-LD (`<script type="application/ld+json">`) on the pages Google
actually gives rich results for — built via pure functions in
`app/lib/structuredData.ts`, no new Payload fields needed, everything
derived from data that already exists.

- **`Organization`** — rendered once, site-wide, in `app/layout.tsx` (via
  `getCompanySettings()`, the same established pattern `/impressum`
  already uses for a public page needing seller data server-side). Only
  non-sensitive fields make it into the schema (name, address, email,
  `vatID`) — `iban`/`bic` never do, even though `getCompanySettings()`
  itself returns them. Has a stable `@id`
  (`https://einfach-produktiv.mk360.de/#organization`) that `Product`/
  `BlogPosting` schemas elsewhere link back to via `{ "@id": ... }`
  instead of repeating the full object on every page (schema.org's own
  recommended pattern for a single canonical entity). Falls back to a
  minimal `{name, url}`-only Organization if `getCompanySettings()`
  can't reach the backend, rather than emitting nothing.
- **`Product`** — only on `/todo-cards`, the one page with its own
  dedicated URL for a single, purchasable product (`getProductBySlug`).
  Deliberately not added to `/shop`'s grid — most products there have no
  individual detail page to point a `Product`'s `url` at, and Google's
  own guidance is that Product markup belongs on the page where that
  product can actually be viewed/bought, not a generic listing.
  `aggregateRating` is omitted — no reviews/ratings system exists yet
  (see the SOTA-gaps discussion this same session); add it once real
  reviews exist, don't fake it before then.
- **`BlogPosting`** — on every `/blog/[slug]` page (`buildArticleSchema`).
  `author` is hardcoded `{ "@type": "Person", name: "Björn" }`, matching
  the page's own hardcoded author-bio block — this is a single-author
  blog with no `author` field on `Posts.ts` to read from instead.

Verified locally by curling each page and grepping the rendered
`application/ld+json` script for the expected `@type` — not run through
Google's Rich Results Test (no live Stripe-style external validation
step for this), so worth a manual check there once deployed.

## Structural breakpoint moved to 640px (2026-07-29)

The Tablet layout (768-1023px) was still not landing right after the
2026-07-24 fixes above: real tablets (768px+) hit the structural `md:`
grid switch at the exact viewport width where the fluid `clamp()` tokens
(`app/lib/fluid.ts`) were already at their smallest (floor) value, leaving
no room to shrink — cramped images/text in 2-column sections. Fixed by
moving both the fluid floor and the structural switch down together, from
768px to Tailwind's `sm:` (640px):

- **`app/lib/fluid.ts`**: `MIN_VW` 768 → 640. All `clamp()` values in
  `app/globals.css`'s `@theme`/`:root` blocks are hand-authored (no build
  step calls `fluid()`), so they were individually regenerated for the new
  640→1440 range, not just re-anchored by editing the constant.
- **Hero/About/Newsletter/Footer/Tools** — the `md:`+`lg:` two-tier
  patchwork from the 2026-07-24 fixes above collapsed back onto a single
  `sm:` structural line (grid/flex switches now happen at 640px, aligned
  with the new fluid floor). Content-sizing exceptions that still don't
  fit at 640px (Hero/Tools' fixed-size CTA/text/icon swaps, Newsletter's
  input+button row, About's inner quote/bio row) stay gated behind `lg:`,
  documented in each file as a deliberate exception, not a leftover.
- Every other component with a plain `md:` structural switch (TrustRow,
  Blog, TestimonialsGrid, ProductSpotlight, ProductGrid, RelatedProducts,
  NewsletterModal, `/blog`, `/not-found`, both `HowItWorks` components)
  renamed mechanically to `sm:`.
- Components with a genuine fixed-width constraint (Cart/Checkout's
  two-column split, `SectionTOC.tsx` + the 5 legal pages, the newsletter/
  todo-cards hero/benefits sections, `/challenge`) stay on `lg:` — the
  math still doesn't fit at 640px either, only their code comments'
  "768px floor" language was updated.
- **`Navbar.tsx`** is a deliberate, documented exception to the whole
  migration — its `md:`/`lg:` 3-tier scheme (hamburger-only, hamburger+
  inline-CTAs, full-inline) is about horizontal nav-link overflow, a
  different failure mode than the vertical grid/flex reflows everywhere
  else, with no fluid token that would make a full inline nav fit at
  640px.

No browser/screenshot tool is available in this environment, so this
migration is verified by typecheck/build/lint/`test:unit` only — actual
visual confirmation at 640/768/1024/1440px is still owed by the user.
**Update, same day:** a real headless-browser check turned out to be
possible after all — `npx playwright install chromium` in a scratch
directory, then a plain Node script driving `playwright`'s `chromium.launch()`
against the *live* site at a specific viewport, reading
`document.documentElement.scrollWidth` vs. `window.innerWidth` (a real
horizontal overflow, not just "looks cramped") and `getBoundingClientRect()`
on specific elements. This is a real, repeatable verification tool for
future Tablet-layout work in this environment — see the two regressions
below, both caught this way, not by guessing from a screenshot.

Same session: **`Hero.tsx`'s heading** shortened from two sentences
("Verliere dich nicht im Mehr. Finde heraus, was wichtig ist.") to one
("Finde heraus, was wichtig ist") with no trailing period, since the
brand's orange dot (`PopIn`) already renders one, animated, right after
it. The homepage's **"3x3-System" werkzeuge-card** description was also
lengthened to match the other two cards' length (see the Payload repo's
own README for the `fix-3x3-copy-length.ts` one-off that changed the
live content).

**Correction, 2026-07-29 (later the same day): `Footer.tsx`'s legal-links
row and `TrustRow.tsx` were wrongly reclassified as plain `sm:` renames
above.** Both had originally used `lg:flex-row` for a genuine
fixed-content-width reason (logo + handle + 5 `whitespace-nowrap` legal
links; multiple `whitespace-nowrap` title+description trust badges) — not
the "grid arrives before the fluid floor" bug the `sm:` migration
targets. Renaming them to `sm:` during the mechanical mass-rename pass
caused a **real horizontal page overflow** in the 640-1023px band on
every page that renders them (Footer: every page; TrustRow: shop, cart,
checkout, order-confirmation, `/agb`, `/widerruf`, `/not-found`) —
confirmed via the Playwright method above: `scrollWidth` exceeded the
viewport by ~80-100px at 666-768px viewports. Both reverted to `lg:`,
matching their original (correct) behavior. **Lesson: "this looks
like the same md:→sm: pattern as everything else" isn't sufficient
justification on its own — check whether a component was *already* on
`lg:` for a real fixed-width-content reason (not just inherited from an
earlier, unrelated Tablet fix) before reclassifying it as a mechanical
rename.** See the `figma-to-nextjs` skill's Gotcha 21 for the fuller
writeup.

**Follow-up, same day: `TrustRow.tsx`'s alignment went through three more
rounds after the `lg:` revert above, all centered on the same
underlying flexbox lesson (skill Gotcha 22).** First, centering its
badges when stacked (per feedback) was implemented as plain `items-center`
directly on the flex-col container — but `align-items:center` centers
*each item independently* within the container's own width, so the 3
badges (different title/description lengths) ended up with 3 different
left edges instead of lining up with each other (confirmed via a real
screenshot: icons at different x-positions). Fixed by wrapping all
badges in one inner shrink-to-fit group with `items-start` internally
(so they share one left edge), then centering/left-aligning that single
group as a unit. **Then tried replacing that whole approach** with `flex
flex-wrap justify-center` directly on the badges (no inner wrapper, no
breakpoint at all): flexbox does correctly center each *wrapped line* as
a group (no misalignment bug), and it removes the overflow risk without
a `lg:`-only breakpoint, so badges flowed as many-per-row as fit (1 per
row on a narrow phone, 2-with-the-3rd-below once there's room, all 3 in
a row at Desktop). **Reverted the same day** once actually screenshotted:
with exactly 3 badges, the 2-per-row-plus-1-wrapped layout put the lone
third badge off to one side, aligned under neither badge above it —
read as "durcheinander"/disorganized rather than clean. Back to the
inner-shrink-wrap single-column approach (strict 1-per-row below `lg:`,
which stays visually tidy regardless of badge count). **Lesson:**
`flex-wrap` is a real, correct fix for the alignment/overflow problem,
but an odd item count wrapping into a partial last row is its own
separate aesthetic risk, worth a real screenshot at the exact width
where the wrap count changes before trusting it as final. `Footer.tsx`'s
legal-links row was left on its plain `lg:` fix throughout — never
asked to change, and its content (nowrap links, not title/description
pairs) doesn't have the same "which items share a line" concern.

**Also same day: `NewsletterModal.tsx`'s close button** was `position:
absolute` inside the dialog's own `overflow-y-auto` scroll container, so
scrolling the modal's content scrolled the close button away with it.
Switched to `position: sticky` (`sticky top-6 ml-auto mr-6 -mb-6`) so it
stays pinned to the top-right corner of the visible (scrolled) area —
see the skill's Gotcha 23.

## Tests

`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
needed) — no test infrastructure existed in this repo before; started with
the pure logic most likely to silently produce wrong numbers on a live
order, not attempted exhaustive coverage:

- **`app/lib/__tests__/cartTotals.test.ts`** — subtotal/discount/shipping
  math (`computeSubtotal`, `computeCartTotals`), incl. the fixed-discount
  clamp and `compareAtPrice`-based savings display being independent of
  the discount-code math.
- **`app/lib/__tests__/bundleContents.test.ts`** — `describeBundleContents()`,
  extracted out of `app/api/checkout/route.ts` into its own module
  (`app/lib/bundleContents.ts`) specifically so it's importable from a
  test — Next.js `route.ts` files only allow HTTP-method (+ a few config)
  exports, not arbitrary named ones.

The original invoice's `isPaidImmediately()`/`groupByTaxRate()` and the
Gutschrift/Stornorechnung money math (`resolveLineItems()`,
`groupByTaxRate()`) both live in `@einfach-produktiv/invoicing` now (see
"Invoice PDFs" above), not in this repo — their tests moved with them
into that package's own `src/__tests__/`, run via that package's own
`vitest`, not this repo's `test:unit`. The Payload-side `orders.ts`
field-lock security logic is still tested in the **Payload backend's**
own `test:unit` — see that repo's README's own "Tests" section, since
that's where that logic actually lives.

## 2026-07-30 changes

**Blog posts can have multiple categories.** `BlogPost.categories` is now
`string[]` (was a single `category: string`), joined with `", "` wherever
a single category used to render (`/blog`, `/blog/[slug]`,
`LivePostContent.tsx`, `components/Blog.tsx`). Backend field is
`Posts.categories`, a `hasMany` relationship — see the Payload backend's
own README.

**Checkout's "Abweichende Lieferadresse" gained Firma + Kontakt-E-Mail/
Telefon** (all optional) — handed to the shipping carrier, not used for
any customer communication (that stays the account email above). Backed
by new `Orders.shipping*` fields with no shipping-side `vatId` (billing-
only concept, deliberately not mirrored).

**The customer profile (`/konto/profil`) can now store its own shipping
address**, mirroring the checkout's own "Abweichende Lieferadresse"
section field-for-field (same checkbox pattern, same fields incl. the new
Firma/Kontakt fields above) — prefills the checkout section for a
returning customer instead of always starting blank. Backed by
`Customers`' new "Lieferadresse" tab in the Payload backend.

**Products can have an optional SKU** even without variants
(`Products.sku` — variants already had their own). Snapshotted onto each
order item at checkout (`app/api/checkout/route.ts` — variant SKU takes
precedence over the product's own) and shown as "Art.-Nr." on the invoice
PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`.

**`@einfach-produktiv/invoicing` bumped to 0.2.7** for the SKU +
shipping-contact rendering above — re-run `npm install
@einfach-produktiv/invoicing` after pulling if your local `node_modules`
predates this.

Full plan for an n8n-driven blog-post content-automation pipeline (not yet
built) lives in the Payload backend repo's `docs/blog-automation-plan.md`,
not duplicated here since it changes independently of this frontend.

**Switching an unpaid Überweisung order to Stripe.** New "Zahlungsart
ändern" button on `/konto/bestellungen/[orderNumber]`, shown when an order
is still `paymentProvider: 'manual'`, `status: 'received'`,
`paymentStatus` not yet `'paid'`, and an active Stripe payment method
exists. `POST /api/account/orders/[orderNumber]/switch-to-stripe` creates
a real PaymentIntent (`app/lib/payments`, same code checkout itself uses)
and hands it to the backend's `switchPaymentToStripeEndpoint`. Reuses
`PaymentStep` (checkout's own Stripe collection UI) and
`/checkout/verarbeitung`'s polling page — both now take a
`returnContext`/`context` prop/param so payment confirmation lands back
on the order page instead of clearing the cart and redirecting to
`/bestellbestaetigung`, which would be wrong for an order that was
already placed and confirmed.

Also: a payment status badge (Offen/Bezahlt/…) next to the existing
fulfillment status badge, on both the order list (`/konto/bestellungen`)
and the detail page — "Offen" renders in the same red/subtle-background
style as a failed status, not the neutral grey the fulfillment status
badge uses for its own "in progress" states, since an unpaid order is
worth visually flagging. A one-line mention of the switch option was
added to the Vorkasse unpaid notice in the order-confirmation email,
shown only when `hasOnlinePaymentOption` is true (an active Stripe method
actually exists).

**Fixed the email-preview page** (`/email-preview/[type]`) — it rendered
the email HTML (a full `<body>...</body>` fragment) via
`dangerouslySetInnerHTML` into a plain `<div>`, nesting it inside the
page's own already-existing `<body>` — invalid HTML the browser silently
mangled, which is why the preview looked broken (wrong background/
padding/font). Now renders via `<iframe srcDoc>`, giving the email its
own real document context. Also added a second sample fixture
(`SAMPLE_ORDER_MANUAL`) with a toggle, since `SAMPLE_ORDER` alone always
had `isManualPayment: false` — the Vorkasse/Überweisung branch (and its
new switch-option mention) was never previewable in the admin at all
before this.

**Follow-up same day: switch-to-stripe eligibility narrowed.**
`canSwitchPayment` (page.tsx) and the `switch-to-stripe` route's own
re-check both changed from `paymentStatus !== "paid"` to an explicit
allowlist (`"pending"` or `"not_applicable"`) — see the Payload backend's
own README for why the blocklist form was too permissive. No visible
behavior change for the normal case, just closes an edge case
(`"failed"`/`"refunded"`/`"partially_refunded"` on a manual order — not
realistic today, but not meaningful states to switch *from* either).

**Two more same-day fixes:**
- `customerOrderAction()` now only offers "Rücksendung anfragen" once an
  order is actually `delivered`, not already at `shipped` — a (partial)
  return before the package has even arrived doesn't make sense yet. The
  backend's own `CUSTOMER_ALLOWED_TRANSITIONS` still technically permits
  `shipped` → `return_requested` too (an extensive existing test suite is
  built around that as its base fixture, see the Payload backend's
  README) — this only narrows what the UI itself offers, a stricter
  subset of what the backend already allows, not a security boundary
  being loosened.
- `/impressum`'s "Angaben zum Anbieter" email (`AnbieterAngaben.tsx`) was
  plain text, not a `mailto:` link — the only email address on the whole
  site that wasn't clickable, since this one section is rendered
  straight from `company-settings` fields rather than through the CMS
  richText renderer (which already links emails/URLs correctly).

**New `sendPaymentSwitchedEmail()`** (`lib/orderEmail.ts`) — sent instead
of `sendOrderConfirmationEmail()` when `confirmPaymentEmail.ts` sees
`order.paymentSwitchedAt` set (a payment confirmation that came from
switching an existing Überweisung order to Stripe, not a fresh gated
checkout — see the Payload backend's own README on
`switchPaymentToStripeEndpoint`). Same shape as the order-status emails
(icon + admin-editable heading/bodyText + "Bestellung ansehen" button via
`renderOrderStatusHtml`), no item table, but **does** re-attach a freshly
generated invoice PDF — same `invoiceNumber` as always (never re-issued
for a switch), but `paymentMethodTitle` now reflects the actually-
confirmed instrument, which changes `@einfach-produktiv/invoicing`'s own
`isPaidImmediately()` check from the Vorkasse notice to "✓ Bereits
beglichen", so the customer's copy of "their invoice" should reflect
that. `buildInvoiceAttachment()` was extracted out of
`sendOrderConfirmationEmail()` so both senders share the exact same PDF-
generation call instead of duplicating that ~40-line object literal.
Default fallback copy (used whenever no admin template is saved/active)
matches the existing brand voice (`sendOrderConfirmationEmail`'s own
"Bestellt! Deine Ruhe kann kommen 🎉" fallback) rather than a flat
system-notice tone: *"Erledigt! Deine Zahlung ist da 🎉" / "Deine Zahlung
ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch
weiter, du musst dich um nichts mehr kümmern."* New
`payment-method-switched` `EmailTemplateType`, added to
`/email-preview`'s valid types too.

**Bumped `@einfach-produktiv/invoicing` to 0.2.8** — fixes a font-path
bug that broke every invoice/correction-invoice PDF render *inside the
Payload backend* specifically. See that package's own README for the
Turbopack asset-hashing root cause. (This turned out to be an
incomplete picture — see "Cart bugs" below: 0.2.8 itself broke this
frontend's own client bundle, just not in a way anyone noticed yet at
the time this was written.)

## Order list mobile grid (2026-07-30)

`/konto/bestellungen`'s order cards used plain `flex flex-wrap` for their
6 fields (Bestellnummer, Datum, Artikel, Status, Zahlungsstatus,
Gesamtbetrag), which sized each field to its own content width — so e.g.
"Status"/"Zahlungsstatus" started at a different x position from one
order card to the next depending on how wide that particular badge label
happened to be. Replaced with a single CSS Grid per card
(`grid grid-cols-2 min-[500px]:grid-cols-4`), since a grid's column
tracks are fixed by the container width (identical on every card
regardless of content) rather than by content width.

Went through several rounds of visual feedback:
- Below 500px there just isn't room for 4 tracks without badge labels
  like "In Bearbeitung" overflowing their column, so it drops to 2
  columns there. The 6 fields in DOM order already tile cleanly into 3
  rows of 2 (Bestellnummer/Datum, Artikel/Status,
  Zahlungsstatus/Gesamtbetrag) without any reordering.
- Bestellnummer/Datum deliberately stay at `col-span-1` in both the
  2-column and 4-column layouts, side by side even below 500px — a
  brief `col-span-2` experiment (own full-width row each, to avoid the
  order number wrapping on very narrow phones) was reverted since it
  read worse than the occasional wrap.
- Artikel forces `col-start-1` so it wraps to its own row in the
  4-column layout instead of Grid's auto-placement flowing it into the 2
  cells left empty in row 1 after Bestellnummer/Datum (a harmless no-op
  in the 2-column layout, where it's already first in row 2 anyway).
- The breakpoint moved twice during tuning — first sm (640px) → md
  (768px) → lg (1024px) while the intent was still "keep 2 columns as
  long as possible," then flipped entirely to "stay at 4 columns as long
  as possible" once the actual goal was clarified, then settled on a
  content-driven `min-[500px]` (started at 400px, moved to 500px) once a
  real device screenshot showed 4 columns overflowing on a narrow phone.
- `OrderStatusBadge`/`PaymentStatusBadge` gained `self-start` — as flex
  children of a `flex flex-col` parent (default `align-items: stretch`),
  the badge `<span>` was stretching to the parent's full width despite
  being `inline-flex` itself (flex items are blockified, so `stretch`
  still applies); `self-start` keeps the badge background only as wide
  as its label.

## Cart bugs (2026-07-30)

Two independent bugs, both reported together as "cart count keeps going up
after every logout/login, and `/cart` won't open":

**1. Quantities doubled on every logout/login cycle.** `LogoutButton.tsx`
never cleared the local cart (`ep_cart` in `localStorage`), and
`mergeServerCartIntoLocal()` (`lib/cart.ts`, called after every login) adds
the server-side cart's quantities into the existing local ones
(`existing.qty += qty`) rather than replacing them — correct behavior for
folding in genuine guest-cart additions, wrong once `CartSync.tsx` has
already mirrored the local cart to the server. Since both sides held the
same quantities at logout time, every login added them together, doubling
the total each cycle. Fixed by clearing the local cart on logout — the next
login's merge then starts from empty (or only genuinely new guest-session
items) instead of re-adding already-synced quantities. `readCart()`/
`getCart()` also gained an `Array.isArray()` check after `JSON.parse`
(previously only guarded against parse *syntax* errors, not the parsed
value being some other shape) as a general defensive hardening, though this
turned out not to be the `/cart` crash's actual cause — see below.

**2. `/cart` couldn't open: `@einfach-produktiv/invoicing`'s `fonts.ts`
crashed the client bundle.** Unrelated to bug 1 — this frontend's own
`CartContent.tsx` imports `computeTaxBreakdown` from
`@einfach-produktiv/invoicing`'s barrel `index.ts`, which also re-exports
`invoicePdf.tsx`, which imports `fonts.ts`. The same-day v0.2.8 fix in that
package (see the entry above) made `fonts.ts` call `fileURLToPath` at
module scope unconditionally — fine in Node.js, but `node:url`'s
`fileURLToPath` isn't a real function in a browser bundle's polyfilled
shim, so **every** client bundle that transitively reached this module
crashed on module evaluation (`Uncaught TypeError: fileURLToPath is not a
function`), including `/cart`'s entire client-rendered page. Fixed
upstream in `@einfach-produktiv/invoicing` 0.2.9 (see that package's own
README) by branching on `typeof window === "undefined"` — browser keeps
the original `new URL(..., import.meta.url)` idiom, Node.js keeps the
`fileURLToPath` resolution. Re-ran `npm install @einfach-produktiv/invoicing`
here to pick it up.

## 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
