33f3adb92c
Neither route was mentioned anywhere despite predating this session (/r) or being added this session (/sticker) — every other route/collection this size gets its own section. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2000 lines
129 KiB
Markdown
2000 lines
129 KiB
Markdown
# einfach-produktiv — Frontend
|
||
|
||
Next.js frontend for [einfach-produktiv.mk360.de](https://einfach-produktiv.mk360.de),
|
||
Coolify-managed and deployed from this repo (`git.mk360.de/Marco/einfach-produktiv`).
|
||
For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the
|
||
shared Payload instance), see `~/dev/README.md` — this file only covers what's
|
||
specific to this project.
|
||
|
||
## Stack
|
||
|
||
- **Next.js 16.2.9** (App Router, `output: "standalone"` for a small Docker
|
||
image — see `AGENTS.md` before touching anything version-specific, this
|
||
Next.js release differs from older training-data conventions)
|
||
- **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation
|
||
- **`@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.
|
||
|
||
## Shared modules
|
||
|
||
`@einfach-produktiv/invoicing` (a separate repo, consumed as a git
|
||
dependency — see "Invoice PDFs" below) is where framework-agnostic logic
|
||
shared with the Payload backend lives, so it's written once instead of
|
||
maintained as two independently-drifting copies. Beyond invoice/e-invoice
|
||
PDF generation and tax-breakdown math, it also holds VIES VAT-ID checking,
|
||
VAT-ID format validation, PLZ digit-count validation, and shipping-carrier
|
||
tracking-label logic — all previously hand-duplicated `app/lib/*.ts` files
|
||
in this repo, now imported from the shared package instead. See that
|
||
package's own README for what's in it and why each piece was unified.
|
||
|
||
## 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`. For the full cross-stack picture (backend +
|
||
frontend + n8n + provisioning-script tokens, plus what's automated vs.
|
||
still manual for a new tenant/project) see the infra repo's
|
||
`docker/TENANT_SETUP.md` — this section stays the authoritative source
|
||
for the frontend-specific "why", that doc is the authoritative source for
|
||
the complete list.
|
||
|
||
## 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`, `/konto/registrieren` | Customer login / standalone registration |
|
||
| `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) |
|
||
| `/konto/profil` | Profile/address editing + password change |
|
||
| `/konto/merkliste` | Wishlist (when `wishlistEnabled`, see "Feature toggles" below) |
|
||
| `/challenge` | "Mini-Challenge" tool |
|
||
| `/todo-cards` | "Todo-Karten" tool |
|
||
| `/die-sieben` | "Die Sieben" — free monthly ritual (evergreen concept page only; the WordPress original's monthly-changing edition/archive/template has no content model in this codebase yet, see `app/die-sieben/page.tsx`'s own comment) |
|
||
| `/newsletter` | Newsletter signup |
|
||
| `/versand` | Shipping policy (timeframes, costs) |
|
||
| `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) |
|
||
|
||
### Responsive design
|
||
|
||
The site uses a fluid, `clamp()`-based responsive system (`app/lib/fluid.ts`,
|
||
tokens hand-authored into `app/globals.css`'s `@theme`/`:root` blocks) that
|
||
scales continuously between a 640px floor and a 1440px ceiling — most
|
||
components need no structural breakpoint at all. Where a genuine
|
||
structural reflow is needed (grid-to-stack, flex-direction switches), the
|
||
site uses a single `sm:` (640px) breakpoint, with a documented handful of
|
||
exceptions still gated on `lg:` (1024px) for real fixed-width content:
|
||
the Cart/Checkout two-column split, the legal-page table-of-contents
|
||
sidebar, the newsletter/todo-cards hero and benefits sections,
|
||
`Footer.tsx`'s legal-links row, and `TrustRow.tsx`.
|
||
|
||
Below `lg:` (1024px), the Navbar's hamburger opens a fullscreen nav panel
|
||
(circular reveal animation, a sibling of `<header>` rather than a child so
|
||
the header's own `backdrop-blur` doesn't break the panel's
|
||
fixed-to-viewport positioning) instead of an inline dropdown; the
|
||
wishlist/search icons are hidden below `sm:` (640px) so Account + Cart
|
||
stay the only always-visible icons on narrow phones (see "Feature
|
||
toggles" below). `Navbar.tsx` itself keeps its own separate `md:`/`lg:`
|
||
3-tier scheme for its nav links — horizontal nav-link overflow is a
|
||
different failure mode than the vertical grid/flex reflows the rest of
|
||
the site handles with `sm:`.
|
||
|
||
### SEO & structured data
|
||
|
||
Every page can carry real per-page metadata — `/blog/[slug]`'s
|
||
`generateMetadata()` reads a post's own `seoTitle`/`seoDescription`/`seoImage`
|
||
(see the `posts` collection below); `app/layout.tsx`'s `generateMetadata()`
|
||
reads `getSeoSettings()` (ISR-cached, backed by `company-settings`) for
|
||
the site-wide fallback. That same `generateMetadata()` also sets
|
||
`verification: { google: seo.googleSearchConsoleVerification }` when an
|
||
admin has filled in company-settings' SEO-tab verification field — Next.js
|
||
renders that natively as `<meta name="google-site-verification">`, no
|
||
separate literal tag in this file.
|
||
|
||
JSON-LD structured data (`app/lib/structuredData.ts`, no headless
|
||
external validation step, just direct grepping of the rendered
|
||
`application/ld+json` script) is emitted for the pages Google gives rich
|
||
results for: an `Organization` schema site-wide in the root layout (from
|
||
`getCompanySettings()`, excluding sensitive fields like `iban`/`bic`,
|
||
with a stable `@id` that other schemas link back to rather than repeating
|
||
the full object), a `Product` schema on `/todo-cards` (the one page with
|
||
its own dedicated single-product URL — deliberately not on `/shop`'s
|
||
grid, since most products there have no individual detail page to point
|
||
a `Product`'s `url` at), and a `BlogPosting` schema on every
|
||
`/blog/[slug]` page.
|
||
|
||
## 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), `categories` (optional, `hasMany` relation to the backend's `product-categories` collection — own collection, not shared with blog's `categories`, so a "Karten" product category and a same-named blog category never collide; not required, so an untagged product still shows everywhere, just matches every `/shop` category filter instead of being excluded — see `Product.categories`' own comment in `lib/payload.ts`), `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), `sku` (optional, even without variants — variants carry their own; snapshotted onto each order item at checkout and shown as "Art.-Nr." on the invoice PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`) |
|
||
| `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`, `categories` (`hasMany` relation to `categories` — a post can have several, joined with `", "` wherever rendered), `excerpt`, `thumbnail`, `content` (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via `app/components/RichText.tsx`'s `@payloadcms/richtext-lexical/react` renderer with custom `JSXConverters`), `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`), `seoTitle`/`seoDescription`/`seoImage` (each falls back to `title`/`excerpt`/`thumbnail` when empty, consumed by `/blog/[slug]`'s `generateMetadata()`) |
|
||
| `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), `contentPart2` (optional second richText, only populated where a dynamic section sits MID-document — currently just AGB's §2 Vertragspartner, `content` = §1, `contentPart2` = §3 onward), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF). Each page's "Stand: …" line is derived from this doc's own `updatedAt` (`formatMonthYear()` in `app/lib/format.ts`), not a hand-typed string — it updates automatically the moment an admin edits the content, no separate field to remember. Name/address/email are NOT hand-typed into any of these richTexts — `AnbieterAngaben.tsx` (Impressum), `VerantwortlicherBlock.tsx` (Datenschutz §1, prepended since it's the very first section) and `VertragspartnerBlock.tsx` (AGB §2, sits between `content` and `contentPart2` since it's mid-document) all render it live from `company-settings` instead, so it can't drift out of sync the way a second hand-typed copy did before (confirmed drifted at least once — AGB's seed script still had placeholder "Björn Wendt"/"Musterstraße 12" long after the real address was set live) |
|
||
| `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 10 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`payment-method-switched`/`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 10 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.**
|
||
`Product`/its variants 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.
|
||
|
||
**Stripe is live in production for Kreditkarte.** `payment-methods` rows:
|
||
Kreditkarte → `provider: 'stripe'`, active; PayPal → `provider: 'stripe'`,
|
||
prepared but not yet activated on the Stripe account itself;
|
||
"Überweisung (Vorkasse)" → `provider: 'manual'`; "Sofortüberweisung"
|
||
prepared as `provider: 'stripe'` but `active: false` pending a logo (see
|
||
the backend's own README). `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
|
||
`NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, and `PAYMENT_WEBHOOK_SECRET` are set
|
||
in both Coolify and the backend's `docker/.env` (the backend's own copy of
|
||
`STRIPE_SECRET_KEY` is used only for `stripeRefund.ts`, Storno/Gutschrift
|
||
refunds). Going from test-mode keys (`sk_test_...`) to live-mode keys
|
||
(`sk_live_...`) is a straight swap of the same three Coolify variables
|
||
plus a new live-mode webhook endpoint (new `whsec_...`) — no code change.
|
||
|
||
### 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** — 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.** The invoice PDF
|
||
already shows this (see `@einfach-produktiv/invoicing`'s own
|
||
`unpaidNoticeText`), but a customer often only glances at the email body
|
||
itself, not the attached PDF. `OrderConfirmationData`'s required
|
||
`isManualPayment: boolean` field is **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 could silently break (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`) 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, plus optional Firma and
|
||
Kontakt-E-Mail/Telefon handed to the shipping carrier only, never used for
|
||
customer communication — that stays the account email) — 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`/`shippingCompany`/
|
||
`shippingContactEmail`/`shippingContactPhone` — mirrored 1:1 on the
|
||
Payload `orders` collection, see the Payload README; there's no
|
||
`shippingVatId` — VAT ID is a billing-only concept, deliberately not
|
||
mirrored) 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. `/konto/profil`
|
||
can store this same deviating-shipping-address shape (same checkbox
|
||
pattern, same fields) as a saved default, prefilling checkout's own
|
||
section for a returning customer instead of always starting blank.
|
||
|
||
### DHL checkout integrations
|
||
|
||
Three checkout-facing pieces, each backed by the Payload backend's
|
||
tenant-configurable `dhl-settings` and gracefully absent (the underlying
|
||
calls 404) whenever a tenant hasn't activated the matching DHL
|
||
sub-integration — see `docker/payload`'s `src/lib/shipping/README.md` for
|
||
the backend side.
|
||
|
||
- **`app/lib/shippingDhl.ts`** — a thin client for the backend's two
|
||
public DHL endpoints (Postnummer validation, DataFactory address
|
||
autocomplete); its own small copy, not part of the shared invoicing
|
||
package.
|
||
- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and
|
||
**`app/api/checkout/autocomplete-address/route.ts`** — Next.js API
|
||
routes proxying to the backend, since `CheckoutContent.tsx` is a Client
|
||
Component and DHL credentials are tenant-specific (live in Payload) —
|
||
the browser never talks to Payload's DHL endpoints directly.
|
||
- **`app/checkout/components/AddressAutocomplete.tsx`** wraps the billing
|
||
and shipping street inputs with a debounced DHL DataFactory suggestion
|
||
dropdown; selecting a suggestion also fills zip/city.
|
||
- **Postnummer live validation** — `CheckoutContent.tsx`'s
|
||
`handlePostNumberBlur` (same shape/status-state pattern as the VAT-ID
|
||
blur check) checks the Packstation Postnummer against DHL on blur, once
|
||
the existing format check (6–10 digits) already passes.
|
||
- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows a
|
||
"Retourenschein herunterladen" link once the backend has generated a DHL
|
||
return label for that order (`order.dhlReturnLabelMedia`, resolved via
|
||
`getMediaUrlById()` in `app/lib/payload.ts`).
|
||
|
||
### 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.
|
||
|
||
**Shared `ProductCard.tsx`**: `ProductGrid.tsx` (shop grid),
|
||
`RelatedProducts.tsx` (cart upsell), and `MerklisteGrid.tsx`
|
||
(`/konto/merkliste`) all render the same card via `app/components/
|
||
ProductCard.tsx` — each used to independently duplicate this markup and
|
||
had drifted (different image aspect ratios, a "Mehr erfahren" link
|
||
present on some but not others, no title link anywhere). The product
|
||
title is now the card's only link (`product.href`, when set) — the old
|
||
separate "Mehr erfahren" CTA line is gone, which is also what makes the
|
||
card more compact. `belowPrice` (optional `ReactNode`) is the one real
|
||
per-grid customization point — `ProductGrid.tsx` uses it for its
|
||
delivery-time line, the other two grids pass nothing. `topLeftBadge`
|
||
similarly lets `MerklisteGrid.tsx` override the default badge with its
|
||
own "Gekauft am …" pill for already-purchased items.
|
||
|
||
**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);
|
||
`ProductCard.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 in `ProductCard.tsx`/`ProductSpotlight.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. `ProductCard.tsx` always renders the line's slot
|
||
(`min-h-[1.05rem]`, empty when not low-stock, unconditionally — even for
|
||
a fully-out-of-stock product, whose `NotifyMeForm` row is height-matched
|
||
against this exact reserved slot being present, see "Back-in-stock
|
||
notifications" below) so every card in a row reserves the same space
|
||
regardless of state, plus a `flex-1` spacer pinning the button/form to the
|
||
same Y across a row. 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 — `@einfach-produktiv/invoicing`'s `buildTrackingUrl(carrier, trackingNumber)`/
|
||
`CARRIER_LABELS` (imported from the package's main barrel) are shared with
|
||
the Payload backend, 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 — see that package's own README
|
||
for the full renderer/formatter surface.
|
||
- **`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, no 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). Rendered as plain green text, not a tinted
|
||
pill/badge box. This one is genuinely conditional on the order's own
|
||
payment method — unlike the bank-details line below, which prints
|
||
unconditionally.
|
||
- **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 — structured, discrete PaymentMeans data
|
||
rather than a single free-text field, per EN16931. When `iban` or `bic`
|
||
is set, the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …"
|
||
**always**, regardless of the order's payment method — a card/PayPal
|
||
customer might still want the seller's bank details for other reasons
|
||
(e.g. a refund).
|
||
- **Summary layout is a genuinely additive chain.** The summary card
|
||
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. The per-rate tax breakdown (from
|
||
`computeTaxBreakdown()`, which distributes discount/shipping
|
||
proportionally across each tax-rate group before computing net/tax, per
|
||
how ancillary costs are legally apportioned across rates) sits **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` uses 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 reads
|
||
"Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead.
|
||
- **Netto row**: 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).
|
||
- **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 has its own sequence, distinct
|
||
from ordinary invoices). 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).** 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
|
||
|
||
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
|
||
(`@einfach-produktiv/invoicing`'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)
|
||
|
||
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).
|
||
|
||
- **`@einfach-produktiv/invoicing/vies`** (server-only entrypoint) —
|
||
`checkVatIdViaVies()` calls the European Commission's public VIES REST
|
||
API (`POST .../check-vat-number`) 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.** `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 without
|
||
also selecting the whole existing value — 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 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 blur-validated too** — 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 also hard-caps input length** — PLZ
|
||
(`maxLength` = the selected country's own digit count), USt-IdNr.
|
||
(`14`), Packstationnummer (`3`)/Postnummer (`10`, on the backend), on
|
||
top of their own `pattern`/`validate` format checks.
|
||
- **`/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)
|
||
|
||
`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 the frontend.
|
||
|
||
- **`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 10 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
|
||
|
||
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 `newsletterOptIn` checkbox — all sync to Brevo's contact list.
|
||
|
||
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
|
||
`upsertNewsletterContact(email, source)` calls Brevo's
|
||
**double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation` —
|
||
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** — `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** — the form stays visible
|
||
with a small red note below it, exactly like every other inline
|
||
validation error (`useNewsletterSignup.ts` sets `status: "error"`,
|
||
`error: "Diese E-Mail-Adresse ist schon für unseren Newsletter
|
||
angemeldet."`). This error clears on the next interaction
|
||
(`handleEmailChange`/`handleConsentChange` both call a shared
|
||
`clearSubmitError()`), matching standard form-validation behavior
|
||
instead of sitting there until the next submit.
|
||
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
|
||
state + on-blur validation + refocus-on-invalid-submit behind all four
|
||
forms. 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 (`successMessage`) also lives here rather
|
||
than hardcoded per form.
|
||
- `/api/newsletter/subscribe/route.ts` logs `upsertNewsletterContact()`'s
|
||
real failure `reason` server-side (`console.error`) while keeping the
|
||
customer-facing error generic ("Anmeldung ist fehlgeschlagen...") —
|
||
never leaks Brevo's internal error text, but stays diagnosable from the
|
||
server logs (e.g. a misconfigured `BREVO_LIST_ID`).
|
||
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
|
||
the single plain-email-format check shared by every newsletter form
|
||
*and* checkout's own email field.
|
||
- **`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,
|
||
plus `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every signup
|
||
silently no-ops without it) and optionally `BREVO_DOI_REDIRECT_URL`.
|
||
- **`NewsletterModal.tsx`'s left photo** stretches (`items-stretch`,
|
||
`md:aspect-auto`) to match the right column's height — the
|
||
emailError/already-subscribed messages are always rendered with a
|
||
reserved `min-h-[1.05rem]` (never conditionally mounted), so toggling
|
||
them never changes the column's height, and the photo never resizes
|
||
along with it.
|
||
|
||
## 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), or standalone via
|
||
`/konto/registrieren` (`RegisterForm.tsx`, reusing the same
|
||
`/api/account/register` route) for a visitor who wants an account
|
||
independent of a purchase (e.g. to use the wishlist, see below). There's
|
||
no persistent "already a customer? log in" prompt shown to every
|
||
logged-out visitor regardless of relevance — instead, the checkout 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), rendered right under the same
|
||
email field the shopper just typed into rather than asking for it a
|
||
second time in a separate field — a `useEffect` scrolls it into view if
|
||
needed. `handleSubmit`'s own `emailExists` handling (see "Checkout
|
||
registration collisions" below) is the fallback for the case this check
|
||
was skipped or raced. `LoginForm.tsx` honors an optional `?redirect=`
|
||
param (used by the wishlist's login-gate, see below), landing back on
|
||
whatever page sent the visitor to log in instead of always
|
||
`/konto/bestellungen`.
|
||
|
||
- **`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).
|
||
- **`app/konto/components/KontoShell.tsx`** is the shared shell for every
|
||
logged-in `/konto/*` page (bestellungen, merkliste, profil) —
|
||
`AccountNav.tsx` (persistent sidebar on `sm:`+, a horizontal tab bar
|
||
below it) plus the `<main>`/`<Footer>` wrapping used to be duplicated
|
||
per page, with the equivalent of the nav buried as plain links at the
|
||
bottom of the orders list (unreachable once that list got long). Each
|
||
page still owns its own auth-check/redirect/data-fetching and renders
|
||
its own heading; right under that heading it renders
|
||
`AccountIdentity.tsx` ("Eingeloggt als …, Kundennummer …") so that line
|
||
looks identical on every page without `KontoShell` itself needing to
|
||
know each page's title. `AccountNav` also renders the "Abmelden"
|
||
button (logout, folded into the nav rather than living at the bottom of
|
||
a specific page).
|
||
- **`/konto/bestellungen`** lists a customer's own orders — a per-card CSS
|
||
Grid (`grid grid-cols-2 min-[500px]:grid-cols-4`, so every card's
|
||
Bestellnummer/Datum/Artikel/Status/Zahlungsstatus/Gesamtbetrag fields
|
||
line up at identical x-positions regardless of label width, dropping to
|
||
2 columns below 500px), status shown as a colored `OrderStatusBadge.tsx`
|
||
plus a `PaymentStatusBadge.tsx` (Offen/Bezahlt/…, in the same
|
||
red/subtle-background style as a failed fulfillment status when unpaid);
|
||
optional status/paymentStatus/year filters (`OrderFilters.tsx`, 3
|
||
`CustomSelect` dropdowns, URL-search-param-driven, collapsed behind a
|
||
"Filter" toggle below `sm:`) when the tenant's `orderFilterEnabled`
|
||
toggle is on (see "Feature toggles" below). **`/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). A
|
||
"Zahlungsart ändern" button appears when an order is still unpaid
|
||
Überweisung and an active Stripe payment method exists — reuses the same
|
||
Stripe `PaymentStep`/`/checkout/verarbeitung` polling flow checkout
|
||
itself uses (via a `returnContext`/`context` prop so confirmation lands
|
||
back on the order page instead of `/bestellbestaetigung`), and sends
|
||
`sendPaymentSwitchedEmail()` (`lib/orderEmail.ts`) instead of the usual
|
||
order-confirmation mail once payment confirms — same shape as the
|
||
status-change emails, but re-attaches a freshly generated invoice PDF
|
||
(same `invoiceNumber`, never re-issued) now marked "Bereits beglichen"
|
||
instead of showing the Vorkasse notice. `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.
|
||
|
||
### Feature toggles
|
||
|
||
A handful of features are gated behind `CompanySettings` booleans
|
||
(Payload admin → Company Settings → **Features** tab): `wishlistEnabled`,
|
||
`searchEnabled`, `shopFilterEnabled`, `blogFilterEnabled`,
|
||
`orderFilterEnabled`. Each stays entirely invisible in the frontend —
|
||
no icon, no filter UI, no route reachable — until its toggle is switched
|
||
on; if a feature "isn't showing," check that setting first.
|
||
|
||
### Wishlist
|
||
|
||
Backend `wishlist-items` collection (customer + product + optional
|
||
variant, unique per combination, customer-scoped access — see the
|
||
Payload backend's own README). Frontend: `useWishlist.ts` (a fetch-based
|
||
hook with an optimistic toggle + a `window` event so every
|
||
`WishlistButton`/the Navbar badge stay in sync — not `localStorage`-backed
|
||
like the cart, since a wishlist needs a logged-in customer to mean
|
||
anything), `WishlistButton.tsx` (heart icon, redirects to login with
|
||
`?redirect=` if logged out), the Navbar's heart icon + count badge
|
||
(hidden below `sm:` — Account+Cart are the only always-visible icons on
|
||
true mobile, to avoid crowding the nav), and `/konto/merkliste`
|
||
(`MerklisteGrid.tsx`, a Client Component grid so removing an item
|
||
disappears immediately). `Product` carries a `numericId` field (the raw
|
||
Payload id) alongside its slug-based `id`, since `wishlist-items.product`
|
||
is a real numeric relationship, unlike the cart/checkout's slug-keyed
|
||
"commerce id".
|
||
|
||
Buying a wishlisted item never auto-removes it — a customer often
|
||
wishlists something specifically to buy it again (gifts, repurchases).
|
||
Instead `/konto/merkliste` marks it: dimmed image + a "Gekauft am [date]"
|
||
badge (replacing the "Ausverkauft" badge when both would apply). Removal
|
||
stays entirely manual via the same `WishlistButton`.
|
||
`WishlistItem.purchasedAt` (`app/lib/customerAuth.ts`) is never persisted
|
||
on the backend collection — it's derived fresh on every `getWishlist()`
|
||
call by cross-referencing the customer's own orders
|
||
(`getPurchasedVariantMap()`), matched on the exact `(product, variant)`
|
||
pair, excluding `cancelled`/`returned` orders.
|
||
|
||
### Search & filters
|
||
|
||
**Instant search** — `/api/search` runs plain Payload
|
||
`where[...][contains]` queries (Postgres `ILIKE`) against Products +
|
||
Posts, not a dedicated search index; `SearchOverlay.tsx` (debounced
|
||
250ms, grouped results) plus a Navbar search icon (same `hidden sm:`
|
||
treatment as the wishlist icon).
|
||
|
||
**Filters** are URL-search-param-driven everywhere (shareable/bookmarkable,
|
||
no client-side-only state): `/konto/bestellungen`'s status/paymentStatus/year
|
||
dropdowns (`OrderFilters.tsx`, `CustomSelect.tsx`); `/blog`'s category
|
||
toggle chips (`BlogCategoryFilter.tsx` — a Client Component using
|
||
`useRouter()` + `useTransition()`, chips disabled while a navigation is
|
||
pending so rapid clicks can't fire overlapping RSC navigations that
|
||
commit out of order); `/shop`'s left sidebar (`lg:+`) / filter bar
|
||
(`<lg`, both rendered by `ProductGrid.tsx`) — a dual-handle price range
|
||
slider (`PriceRangeFilter.tsx`, drag-to-filter, commits on release not
|
||
per drag-frame), category checkboxes (`CategoryFilter.tsx`, union match
|
||
— a product with no category tagged still matches every filter rather
|
||
than disappearing, see `Product.categories`' own comment in
|
||
`lib/payload.ts`), and a "Nur verfügbare Produkte" availability toggle
|
||
(same component). Sidebar styled to match `AccountNav.tsx`'s label/
|
||
spacing treatment. Both `/blog` and `/shop`'s grids key their
|
||
`RevealGroup` on the actually-rendered item set — `whileInView` only
|
||
fires once per component instance, and a filter change re-renders the
|
||
same page in place rather than remounting it, so without the key a
|
||
freshly filtered list could mount into an already-settled `RevealGroup`
|
||
and stay stuck at `opacity: 0` (reported as the grid "going blank"
|
||
after filtering).
|
||
**`CustomSelect.tsx`** (`app/components/`) is a fully custom-styled
|
||
dropdown (own trigger + `role="listbox"` panel, keyboard nav,
|
||
click-outside-to-close) used wherever a native `<select>`'s un-stylable
|
||
options popup would look off-brand — also used for checkout's two
|
||
country pickers.
|
||
|
||
### 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>` shares the same Payload-configurable country list
|
||
`/checkout` reads (`ProfileForm.tsx` takes a `shippingCountries` prop,
|
||
`page.tsx` fetches `getShippingCountries()`), so a country added/removed
|
||
in the admin reaches both places.
|
||
- **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 10 transactional emails (order confirmation, password reset, the
|
||
payment-method-switched email, and the 7 order-status-change types below)
|
||
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 and payment-method-switched, the
|
||
only two of the 10 actually sent from this repo (password-reset and all
|
||
7 order-lifecycle 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`/`SAMPLE_ORDER_MANUAL` in `emailTemplates.ts`, toggleable —
|
||
the two fixtures cover both the paid and the Vorkasse/Überweisung
|
||
branches) — unlike the other three Live Preview targets, there's no
|
||
"current" real order/reset-link to preview against generically. Renders
|
||
the email HTML inside an `<iframe srcDoc>` rather than
|
||
`dangerouslySetInnerHTML` directly into the page, so the email's own
|
||
`<body>...</body>` fragment gets its own real document context instead
|
||
of nesting invalid HTML inside this page's `<body>`.
|
||
- 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** — each row 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 10 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 7 order-lifecycle 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
|
||
|
||
Several `orders` lifecycle events trigger a customer email — the
|
||
`status` transitions `shipped`/`cancelled`/`return_requested`/`returned`/
|
||
`delivered`, plus a tracking number being added or corrected
|
||
(`order-tracking-added`/`order-tracking-corrected`) — not
|
||
`processing`/`received`, which aren't 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
|
||
any of these order-lifecycle rows 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.
|
||
|
||
## Cookie consent, tracking codes & back-in-stock notifications
|
||
|
||
**Cookie consent** (added 2026-08-01) uses **Klaro** (`kiprotect/klaro`,
|
||
npm, self-hosted, open source) — a first hand-rolled `CookieBanner.tsx`/
|
||
`useConsent.ts`/`TrackingScripts.tsx` trio shipped the same day, then was
|
||
replaced with Klaro after real per-service consent lists and bundled
|
||
legally-grade German translations were judged worth the swap over
|
||
building all of that by hand. `no @types/klaro` package exists —
|
||
`types/klaro.d.ts` declares only the small surface actually used
|
||
(`setup`/`show`).
|
||
|
||
- `app/lib/klaroConfig.ts` — builds Klaro's config object dynamically
|
||
from the Payload backend's `tracking-codes` collection (see that
|
||
repo's README): one Klaro "service" per row, `purposes: [consentCategory]`.
|
||
Brand colors applied via Klaro's CSS-custom-property `styling`
|
||
overrides (`green1`/`red1`/`blue1`/`dark1`/`light1` etc. — confusingly
|
||
named: `dark1`/`light1` are the notice's background/text colors, not a
|
||
dark-mode switch; `blue1` colors the modal's "Einstellungen" button and
|
||
is easy to miss since it only shows once the modal itself opens, not
|
||
just the notice). Theme: compact bottom-left corner notice
|
||
(`theme: ["light", "bottom", "left"]`, `notice-max-width: 380px`), not
|
||
Klaro's full-width `wide` default.
|
||
- `app/lib/loadTrackingCode.ts` — the actual `<script>` injection side
|
||
effect (GA4/Facebook Pixel/GTM fixed loader snippets, `other` renders
|
||
the admin-supplied raw script), wired via each Klaro service's
|
||
`callback(consent)` in `klaroConfig.ts`, not declaratively rendered.
|
||
- `app/components/KlaroConsentManager.tsx` — mounts Klaro client-side
|
||
(dynamic `import()`, not a static import — Klaro touches
|
||
`window`/`document` at module-eval time, not SSR-safe) inside
|
||
`app/layout.tsx`. Renders nothing itself if there are zero active
|
||
`tracking-codes` rows (no banner needed with nothing to consent to).
|
||
Also renders a **persistent bottom-left cookie icon button** (fixed
|
||
position, calls `Klaro.show()`) so a visitor can reopen the consent
|
||
manager any time, not just on first visit.
|
||
- **Testing note**: a hard reload does NOT clear the `klaro-consent`
|
||
cookie (180-day expiry) — that's correct/expected behavior, not a bug.
|
||
To re-trigger the first-visit notice while testing, use the reopen
|
||
button, delete the cookie in DevTools, or an incognito window.
|
||
|
||
**Back-in-stock notifications** — `NotifyMeForm.tsx` replaces the
|
||
disabled Add-to-cart button's slot entirely (not stacked below it) once a
|
||
product/variant is out of stock, POSTing to `/api/stock-notifications`
|
||
→ `app/lib/stockNotifications.ts` → the backend's `stock-notifications`
|
||
collection. Always shows the email input directly (no collapse-to-reveal
|
||
step) — rendered as ONE row, the submit control an icon button embedded
|
||
inside the input itself (absolutely positioned), not a separate button
|
||
stacked below it. Explicit `h-14` on the input matches
|
||
`AddToCartInlineButton`'s own button's actual rendered height, which
|
||
comes from its 30px cart-icon image rather than its padding alone — an
|
||
earlier two-row version (input above a full-width "Benachrichtigen"
|
||
button) made a sold-out `ProductCard` taller than its in-stock siblings;
|
||
matching heights exactly (not just approximately) was the actual ask.
|
||
`AddToCartButton`/`AddToCartInlineButton` both carry a `numericId` prop
|
||
(`product.numericId`, the real Payload id — NOT their own `id`/
|
||
`productId` props, which are the commerce slug) threaded through for
|
||
this, same split `WishlistButton` already used.
|
||
|
||
The email itself (`renderBackInStockHtml` in `app/lib/emailTemplates.ts`)
|
||
has its own dedicated Live Preview renderer, not the shared generic
|
||
order-status one — there's no order for this email type, so no fake
|
||
"Bestellnummer" line; CTA is "Zum Produkt", not "Bestellung ansehen".
|
||
|
||
## Google Maps (consent-gated embed, prepared but unused)
|
||
|
||
No map is embedded anywhere in this app yet — `GoogleMapsEmbed.tsx`
|
||
exists as ready-to-use prep work. Unlike GA/Facebook Pixel/GTM (scripts
|
||
this app injects itself, see the tracking-codes section above), an
|
||
embedded iframe is gated by **Klaro's own built-in contextual-consent
|
||
mechanism**, not `loadTrackingCode.ts`: the iframe renders with its real
|
||
`src` and a `data-name="google-maps"` attribute; Klaro's DOM scan
|
||
(`renderContextualConsentNotices`, runs as part of `Klaro.render()`)
|
||
finds it after mount, blanks `src` and swaps in its own "Karte laden?"
|
||
placeholder (styled to match this site via `KlaroConsentManager.tsx`'s
|
||
`KlaroTheme`) until that service's consent is granted, then restores the
|
||
real `src`. `loadTrackingCode.ts`'s `google-maps` case is a deliberate
|
||
no-op — there's nothing to inject, Klaro handles the swap entirely on its
|
||
own.
|
||
|
||
Backend prerequisite: exactly one `tracking-codes` row with
|
||
`provider: google-maps` must exist and be `active` (see the Payload
|
||
README) — `klaroConfig.ts`'s `serviceName()` special-cases this provider
|
||
to the fixed name `"google-maps"` (not id-based like every other
|
||
provider) since there's no distinguishing ID the way GA/FB/GTM each have
|
||
one; a second `google-maps` row would collide on that name. To actually
|
||
use it: `<GoogleMapsEmbed src="<Google Maps embed URL>" title="..." />`
|
||
anywhere.
|
||
|
||
## Short links & QR redirects
|
||
|
||
`app/r/[code]/route.ts` and `app/sticker/[code]/route.ts` are static
|
||
short-link routes (e.g. printed on a flyer or a physical sticker's QR
|
||
code) that resolve a `code` against Payload's `redirects` collection and
|
||
307-redirect (`redirect()`, deliberately not `permanentRedirect()` — the
|
||
target can change at any time and this must never be client-cached) to
|
||
that doc's `targetPath`. Both routes call the same
|
||
`resolveAndTrackRedirect(code, urlPrefix)` in `app/lib/payload.ts`, which
|
||
also fire-and-forget PATCHes `clickCount`/`lastClickedAt` on every hit
|
||
(tracking failure only logs — never worth stranding a visitor over).
|
||
|
||
- **`/r/[code]`** is the default scheme for all new short links —
|
||
`einfach-produktiv.com/r/aktion-sommer`.
|
||
- **`/sticker/[code]`** exists only because a batch of physical stickers
|
||
was already printed as `einfach-produktiv.com/sticker/<code>` (`echt`,
|
||
`geheimnis`, `fokus`) before `/r/[code]` existed and can't be
|
||
reprinted — same collection, same lookup, just a second fixed prefix.
|
||
- The Redirects doc's `urlPrefix` field (`/r` or `/sticker`) isn't just an
|
||
admin label — `resolveAndTrackRedirect` filters on it too, so a code is
|
||
only reachable under whichever prefix its doc is actually set to.
|
||
Changing `urlPrefix` in Payload immediately changes which route serves
|
||
that code.
|
||
- Defense in depth: `targetPath` must start with `/` and not `//` (open-
|
||
redirect guard, same check `app/api/preview/route.ts` does), enforced
|
||
both in Payload's own field validation and again in each route.ts before
|
||
calling `redirect()`.
|
||
- A deactivated (`active: false`) doc — or no matching doc at all —
|
||
renders a plain 404 (`notFound()`), not a redirect to some fallback.
|
||
|
||
## Product image gallery
|
||
|
||
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
|
||
on mobile touch swipe (left/right, `onTouchStart`/`onTouchEnd`,
|
||
`SWIPE_THRESHOLD_PX`), backed by an optional `Products.gallery` field
|
||
(backend, `hasMany` upload, empty for most products — see the Payload
|
||
README). Renders as a single plain image with no thumbnail row (and no
|
||
swipe handling — nothing to swipe between) when `gallery` is empty, i.e.
|
||
the same appearance every product already had before this existed — a
|
||
product only gets the gallery UI once someone deliberately adds extra
|
||
photos in the admin.
|
||
|
||
There's no generic `/shop/[slug]` product detail page in this app — every
|
||
product beyond the plain grid card gets a bespoke hero page (currently
|
||
just `/todo-cards`), matching the custom-per-client site model.
|
||
`ProductGallery` is wired into `TodoKartenHero.tsx` as a drop-in
|
||
replacement for that page's static hero photo — but only once
|
||
`product.gallery.length > 0`; otherwise the curated static lifestyle
|
||
photo (`/hero-todo-karten.png`, deliberately not just the catalog image)
|
||
stays exactly as it was. A future bespoke product page can reuse the same
|
||
component the same way.
|
||
|
||
## 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.
|
||
|
||
## 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
|