230b8ebaad
A customer often wishlists something specifically to buy it again (gifts, repurchases) — silently removing it after purchase would defeat that. /konto/merkliste now shows a dimmed image + "Gekauft am [date]" badge instead, derived read-only from the customer's own orders (cancelled/returned orders excluded). Removal stays manual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2376 lines
150 KiB
Markdown
2376 lines
150 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.
|
||
|
||
## Getting started
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev
|
||
```
|
||
|
||
Open [http://localhost:3000](http://localhost:3000). `npm run build && npm run
|
||
start` reproduces the production build locally — do this before pushing,
|
||
since Coolify builds with `--no-cache` and a failed build only surfaces there
|
||
otherwise.
|
||
|
||
**Environment variables:** `PAYLOAD_URL` (defaults to `https://payload.mk360.de`
|
||
if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe
|
||
default — required for Live Preview, see below; must match the value set on
|
||
the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the
|
||
same `https://payload.mk360.de` — only needed if the client-side Live
|
||
Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
|
||
(no safe default — required for discount codes to validate/redeem at all;
|
||
must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
|
||
(no safe default — required for `/api/checkout` to persist an order in
|
||
Payload at all, for `/api/account/verify-email` to look up a customer
|
||
by their verification token, and for `getCompanySettings()` to read the
|
||
`company-settings` collection (seller data for invoice PDFs); must match
|
||
the value set on the Payload backend — also used there for the same
|
||
header). `SMTP_USER`/`SMTP_PASSWORD`
|
||
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
||
alerts and resend-verification emails; **does not** need to match anything
|
||
on the Payload side — this app's SMTP connection is deliberately
|
||
independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`,
|
||
`STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`,
|
||
`PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing
|
||
(Stripe)" below. `BREVO_API_KEY`, `BREVO_LIST_ID`,
|
||
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — required for
|
||
newsletter signups to trigger a confirmation email at all),
|
||
`BREVO_DOI_REDIRECT_URL` (optional, defaults to `/newsletter-confirmed`) — see
|
||
"Newsletter signup" below. Set in Coolify's app settings for production,
|
||
not in a committed `.env`.
|
||
|
||
## Pages
|
||
|
||
| Route | Purpose |
|
||
|---|---|
|
||
| `/` | Home — hero, product spotlight, tools grid, trust row |
|
||
| `/shop` | Product grid (all active products for this tenant) |
|
||
| `/blog`, `/blog/[slug]` | Blog overview + post detail |
|
||
| `/cart` | Cart (localStorage-backed, see below) |
|
||
| `/checkout` | Shipping + payment method selection, order summary |
|
||
| `/bestellbestaetigung` | Order confirmation — reads the one-time snapshot `/checkout` wrote |
|
||
| `/konto/login` | Customer login |
|
||
| `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) |
|
||
| `/konto/profil` | Profile/address editing + password change |
|
||
| `/challenge` | "Mini-Challenge" tool |
|
||
| `/todo-cards` | "Todo-Karten" tool |
|
||
| `/newsletter` | Newsletter signup |
|
||
| `/versand` | Shipping policy (timeframes, costs) |
|
||
| `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) |
|
||
|
||
## Content backend: Payload CMS
|
||
|
||
This app is one **tenant** in a shared, multi-tenant Payload instance also
|
||
used by other projects on the same VPS (see `docker/payload/` in the infra
|
||
repo). All content queries go through `app/lib/payload.ts`, which hardcodes
|
||
`TENANT_SLUG = "einfach-produktiv"` and filters every request with
|
||
`where[tenant.slug][equals]=einfach-produktiv` — the tenant field itself is
|
||
injected automatically into every collection below by Payload's
|
||
`multiTenantPlugin`, not defined in this app.
|
||
|
||
`/api/products` is this app's own same-origin proxy route (`app/api/products/route.ts`)
|
||
in front of `getProducts()` — used by client components (cart, related
|
||
products) that need the catalog reactively, so they don't talk to Payload's
|
||
API directly and reuse Next.js's fetch cache instead of an extra round trip.
|
||
|
||
### Collections used by this tenant
|
||
|
||
Most reads are public (`access.read: () => true`); writes are always
|
||
admin-gated in the Payload admin UI at `payload.mk360.de/admin`. A growing
|
||
subset below is **not** public-read at all (`discount-codes`, `orders`,
|
||
`customers`, `number-ranges`, `company-settings`) — each row says so and
|
||
explains what does have access instead.
|
||
|
||
| Collection (slug) | Used for | Key fields |
|
||
|---|---|---|
|
||
| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`), `taxRatePercent` (optional per-product VAT override, see "Product bundles & per-product tax rates" below), `bundleItems` (optional — makes this product a bundle) |
|
||
| `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below |
|
||
| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) |
|
||
| `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) |
|
||
| `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF) |
|
||
| `trust-badges` | Horizontal "Schneller Versand / Versandkostenfrei / Mit Liebe verpackt" row — shown on `/shop`, `/cart`, `/checkout`, `/widerruf`, `/agb`, 404 | `title`, `description` (supports `{{lieferzeit}}`/`{{kostenfreiab}}` placeholders, resolved by the frontend from `shipping-settings`/`shipping-methods` at render time — not by Payload itself), `icon`, `sortOrder` |
|
||
| `cart-trust-badges` | Sidebar bullets on `/cart` (title only) and `/checkout` (title + description) — deliberately a separate collection from `trust-badges` so the two pages can't drift into showing different claims | `title`, `description`, `icon`, `sortOrder` |
|
||
| `shipping-methods` | `/checkout` shipping selection | `title` (no day-range in the title text — that lives in `shipping-settings` now, keeping both in one title used to drift), `description`, `price`, `freeShippingThreshold` (per-method, optional — leave empty for a method that should never be free, e.g. Express), `active` (inactive methods are hidden, not shown disabled), `sortOrder` |
|
||
| `shipping-settings` | Delivery-time disclosure shown on `/shop`, homepage spotlight, ToDo-Karten, `/cart`, `/checkout`, `/versand` (Art. 246a §1 Abs.1 Nr.8 EGBGB requires this visible before checkout) | `handlingDaysMin`/`handlingDaysMax` (processing time before it ships), `transitDaysMin`/`transitDaysMax` (carrier time) — the app derives the combined total itself. One row per tenant. |
|
||
| `payment-methods` | `/checkout` payment selection | `title`, `icons` (array — e.g. 3 logos for "Kreditkarte"), `active`, `sortOrder` |
|
||
| `werkzeuge-cards` | Homepage "Meine Werkzeuge" 3-card grid | `title`, `description`, `icon`, `ctaLabel`, `ctaHref`, `sortOrder` |
|
||
| `testimonials` | Customer testimonial grids on `/todo-cards`, `/newsletter`, `/challenge` | `quote`, `name`, `role`, `avatar`, `page` (`todo-cards`/`newsletter`/`challenge` — which page's grid this appears in), `sortOrder`. The single-quote "photo band" testimonials on `/not-found` and `/bestellbestaetigung` are a different shape (no avatar/role) and stay hardcoded, not part of this collection. |
|
||
| `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) |
|
||
| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer |
|
||
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
|
||
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
|
||
| `email-templates` | Editable subject/heading/body/footer for all 9 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
|
||
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
|
||
|
||
All of the above except `company-settings`, `media`, `users`, `tenants`
|
||
are grouped in the Payload admin sidebar under **Commerce** (`products`,
|
||
`discount-codes`, `orders`, `customers`, `number-ranges`,
|
||
`email-templates`, `shipping-methods`, `shipping-settings`,
|
||
`payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content**
|
||
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`).
|
||
`company-settings` sits in its own **Company** group; `media`/`users`/`tenants`
|
||
sit under **Platform** — `users` and `tenants` are hidden from
|
||
non-super-admins' nav entirely, and every tenant-scoped collection's own
|
||
"assigned tenant" field is hidden from non-super-admins in the edit view
|
||
(though not yet the list view's column — see the infra README's Payload
|
||
CMS section for why that one's a harder fix).
|
||
|
||
**What an admin can actually configure without a code deploy, at a
|
||
glance:** product/shipping/payment catalog data and `active` toggles
|
||
(incl. per-product tax-rate overrides and defining a product as a bundle),
|
||
discount codes, all page content (blog/legal/testimonials/trust badges),
|
||
delivery-time disclosure (`shipping-settings`), order/customer/invoice
|
||
numbering schemes (`number-ranges`), all 6 email wordings
|
||
(`email-templates`, with Live Preview), and invoice seller data,
|
||
bank details, and VAT rate (`company-settings` — also what every email's
|
||
legal footer is sourced from). What still requires a code change:
|
||
adding a new *field* to any collection (needs a migration), payment
|
||
processing itself (not built), and anything structural in
|
||
`orders`/`customers` beyond `status`/`returnReason` and the profile fields
|
||
already exposed on `/konto/profil`.
|
||
|
||
Adding a *new field* to any collection above requires editing the
|
||
collection file in `docker/payload/src/collections/` and a migration,
|
||
which does need a deploy of the Payload service.
|
||
|
||
### Live Preview
|
||
|
||
`posts`, `legal-pages`, and `testimonials` support Payload's Live Preview —
|
||
opening a document in the Payload admin shows this app's real rendered page
|
||
in an iframe, updating as you type, no save required. `email-templates`
|
||
also has Live Preview, but against a synthetic page + sample data rather
|
||
than one of these three's own real page — different enough to cover
|
||
separately, see "Email templates & Live Preview" further below.
|
||
|
||
- **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET`
|
||
(matching value required on the Payload side too, or this route 401s) and
|
||
a `path`, enables Next.js Draft Mode, then redirects into the real page.
|
||
This is the link Payload's `livePreview.url` resolvers point at (see
|
||
`docker/payload/src/lib/previewUrl.ts` in the infra repo) — never the page
|
||
directly.
|
||
- Each supported page checks `draftMode().isEnabled` and renders a
|
||
`"use client"` Live-Preview-aware component instead of the plain static
|
||
one only when it's `true` — ordinary visitors are never in Draft Mode, so
|
||
they always get the plain version with zero extra client JS:
|
||
- `app/components/LiveRichText.tsx` — the 4 legal pages' body content
|
||
(their headings/sidebars are hardcoded per page, not CMS-sourced, so
|
||
`content` is the only field worth live-previewing there)
|
||
- `app/blog/[slug]/components/LivePostContent.tsx` — title/excerpt/
|
||
thumbnail/body of a blog post (the author bio card, "Weiterlesen" card,
|
||
and Footer stay static — they either aren't post-specific or are about
|
||
a *different* post, not the one open in the admin)
|
||
- `app/components/LiveTestimonialsGrid.tsx` — the one testimonial
|
||
currently open in the admin, merged by `id` into the rest of that
|
||
page's already-fetched grid (Live Preview is inherently single-document,
|
||
but this page renders several at once)
|
||
- All three use `@payloadcms/live-preview-react`'s `useLivePreview` hook and
|
||
reuse the same mapping functions (`mapPayloadPost`, `mapPayloadTestimonial`,
|
||
exported from `app/lib/payload.ts`) the plain server-side fetchers use, so
|
||
the two code paths can't silently drift apart.
|
||
- `app/lib/payload.ts` deliberately never imports `next/headers` itself —
|
||
callers (the Server Component pages) call `draftMode()` themselves and
|
||
pass the result in as a plain `{ draft: boolean }` option. Importing
|
||
`next/headers` anywhere in that module breaks the production build the
|
||
moment a `"use client"` component (which also needs this file's mapping
|
||
functions/types) tries to bundle it — a real RSC-boundary regression hit
|
||
once already when adding this feature.
|
||
|
||
## Discount codes
|
||
|
||
Applied in `/cart` only (`/checkout` displays the already-applied result,
|
||
no second input) — real server-side validation, not just a client-side
|
||
check against Payload's public API, unlike most content on this site.
|
||
|
||
- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
|
||
"Anwenden" button, shown whenever no code is currently applied *and*
|
||
Payload actually has at least one active code right now
|
||
(`hasActiveDiscountCode()` in `discountServer.ts`, `where[active][equals]=true`,
|
||
ISR-cached 60s — no point offering an open field that could never
|
||
validate against anything). Once applied, the field is replaced by a
|
||
read-only result + "Entfernen" link, shown regardless of that check (an
|
||
already-applied code, e.g. from an older session, still needs somewhere
|
||
to display even if no *other* code happens to be active right now).
|
||
`?code=SAVE10` on the `/cart` URL still auto-applies once on arrival (a
|
||
`useEffect` reading `useSearchParams()` — requires `/cart`'s `page.tsx`
|
||
to wrap `CartContent` in `<Suspense>`, a Next.js requirement for any
|
||
`useSearchParams()` consumer) regardless of `hasActiveDiscountCode()`
|
||
too, so a marketing link still works without the shopper typing
|
||
anything; if that auto-apply fails, the error shows even without the
|
||
manual field present.
|
||
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
|
||
two route handlers below — never by a `"use client"` component, same
|
||
reasoning as Live Preview's `next/headers` lesson above) talks to
|
||
Payload's `discount-codes` collection using an `x-discount-service-secret`
|
||
header (`DISCOUNT_SERVICE_SECRET`), since that collection isn't
|
||
public-read.
|
||
- **`app/api/discount/validate/route.ts`** — read-only check (active /
|
||
validity window / minimum order value / remaining redemptions), called
|
||
when a shopper clicks "Anwenden" in the cart.
|
||
- **`app/api/discount/redeem/route.ts`** — re-validates, then increments
|
||
the collection's `redemptionCount`. Called exactly once, from
|
||
`CheckoutContent.tsx`'s `handlePurchase()`, right before the
|
||
`OrderSnapshot` is written — a code that expired or hit its redemption
|
||
cap between being applied in the cart and the actual purchase click
|
||
fails the purchase with an inline error instead of silently completing.
|
||
- **`app/lib/discount.ts`** mirrors `lib/cart.ts`'s exact `localStorage` +
|
||
`useSyncExternalStore` pattern, so the applied code survives the
|
||
`/cart` → `/checkout` navigation the same way the cart itself does.
|
||
- **`app/lib/cartTotals.ts`** — `computeSubtotal()`/`computeCartTotals()`,
|
||
factored out of what used to be independently-duplicated subtotal/
|
||
savings/total math in `CartContent.tsx`, `CheckoutContent.tsx`, and
|
||
`BestellbestaetigungContent.tsx`; now also folds in the discount amount
|
||
(clamped so a total can never go negative). `OrderSnapshot` persists the
|
||
applied `discountCode`/`discountAmount` so the confirmation page shows
|
||
what actually happened, not a fresh re-derivation.
|
||
- Known, accepted limitation: the redeem route's read-then-increment isn't
|
||
atomic against a true concurrent race on a capped code's very last
|
||
redemption — not worth custom atomic SQL at this shop's traffic level.
|
||
|
||
## Cart & checkout
|
||
|
||
- **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in
|
||
`localStorage` under `ep_cart`, keyed by each product's `slug`. Still the
|
||
source of truth while browsing — the server-side mirror (see below) only
|
||
exists to carry a logged-in customer's cart across devices/browsers.
|
||
- **Add-to-cart is capped at actual remaining stock (fixed 2026-07-23)** —
|
||
previously only checked at checkout, so a shopper could add more of a
|
||
product than was actually in stock and only find out at the last step.
|
||
`Product`/its variants now carry a real `maxQty` (`app/lib/payload.ts`'s
|
||
`mapPayloadProduct()` — `null` when unlimited, i.e. backorder allowed or
|
||
inventory untracked; a deliberate, narrow exception to that function's
|
||
own "the public API has no reason to leak exact stock counts" comment,
|
||
since the add-to-cart controls genuinely need it). `AddToCartButton`/
|
||
`AddToCartInlineButton` disable (and show "Maximale Menge im Warenkorb")
|
||
once the cart already holds that many; `/cart`'s quantity `<select>`
|
||
caps its option range the same way instead of always offering a flat 1–9.
|
||
`api/checkout/route.ts`'s own stock re-check stays as the authoritative
|
||
server-side guard regardless.
|
||
- **`app/cart/components/RelatedProducts.tsx`** only ever suggests products
|
||
not already in the cart — it stopped falling back to re-suggesting an
|
||
already-in-cart product just to pad the grid out to 3 cards, so with a
|
||
small catalog it can render fewer cards (down to 1, centered in the
|
||
12-column grid) rather than recommending something already added.
|
||
- **`/checkout`'s "Jetzt kaufen" always goes through
|
||
`POST /api/checkout`.** That route re-prices the entire cart server-side
|
||
from Payload's live product data (never trusts client-submitted prices),
|
||
re-validates+redeems a discount code exactly once, registers a new
|
||
account inline if nobody's logged in yet ("Konto Pflicht" — see below),
|
||
and only then creates the order in Payload's `orders` collection via
|
||
`app/lib/orderServer.ts`. `subtotal`/`discountAmount`/`total` are each
|
||
rounded to 2 decimals (`roundMoney()`) right before being persisted —
|
||
plain float arithmetic on a summed/percent-discounted cart drifts into
|
||
values like `84.30000000000001`, invisible wherever a display already
|
||
ran the number through `formatPrice()`'s `toFixed(2)`, but stored as-is
|
||
otherwise and visible raw in the Payload admin's plain number field for
|
||
`total`. `app/lib/order.ts`'s `OrderSnapshot` is still
|
||
written to `sessionStorage` for `/bestellbestaetigung` to read once, but
|
||
its `orderNumber`/`orderDateIso` now come back from that Payload create
|
||
call, not generated client-side.
|
||
- **Order confirmation email** is sent from `/api/checkout/route.ts`
|
||
right after a successful `createOrder()` for Überweisung orders only —
|
||
fire-and-forget
|
||
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
|
||
or fails the checkout response itself; a send failure alerts admin
|
||
instead (`sendCriticalAlert`, lower severity than the "order not
|
||
persisted" alert, since the order itself is safe either way). Content
|
||
comes from the **published** `order-confirmation` row in Payload's
|
||
`email-templates` collection — see "Email templates & Live Preview"
|
||
below for how that's edited/previewed. As of the invoice PDF feature
|
||
(see below), this same send also carries the order's invoice PDF as an
|
||
attachment. Kreditkarte/PayPal orders defer this until payment is
|
||
confirmed — see "Payment processing (Stripe)" below.
|
||
|
||
### Payment processing (Stripe)
|
||
|
||
Real payment capture for Kreditkarte/PayPal, via Stripe's Payment Element
|
||
(one integration covers both — see the approved plan this was built from,
|
||
`spicy-leaping-pizza.md`, for the full design rationale). Überweisung stays
|
||
exactly as before: no gateway involved, order goes straight to `received`.
|
||
|
||
- **`payment-methods`'s `provider` field** (Payload, admin-only) drives the
|
||
branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal).
|
||
`app/lib/payload.ts`'s `getPaymentMethods()` exposes it; the checkout
|
||
route re-resolves it server-side, never trusts a client-submitted value.
|
||
- **Checkout UI collapses Kreditkarte + PayPal into one "Online-Zahlung"
|
||
option** (`groupPaymentMethodsForCheckout()` in `app/lib/payload.ts`,
|
||
used by `CheckoutContent.tsx`). Both admin rows still exist and both
|
||
still need `provider: 'stripe'` — this is a display-layer grouping, not
|
||
a data change. Reasoning: the PaymentIntent is created with
|
||
`automatic_payment_methods: { enabled: true }` (Stripe's own recommended
|
||
Payment Element pattern — Stripe itself decides which eligible method to
|
||
show), so pre-selecting "Kreditkarte" vs. "PayPal" before that never
|
||
actually restricted anything; it was redundant friction, not a real
|
||
choice. The combined option shows a hint text ("die genaue Zahlungsart
|
||
wählst du im nächsten Schritt") so the consolidation reads as intentional,
|
||
not a missing option. Überweisung stays a separate, real option.
|
||
- **`paymentMethodTitle` is snapshotted as a neutral `"Online-Zahlung"`**
|
||
at order-creation time for the `stripe` branch (the customer hasn't
|
||
picked an instrument yet at that point) and **refined to the real one**
|
||
(`"Kreditkarte"`/`"PayPal"`) once Stripe reports it —
|
||
`resolveStripePaymentMethodLabel()` in `stripeProvider.ts` reads the
|
||
confirmed PaymentIntent's `payment_method.type` in the webhook route and
|
||
passes it to `confirm-payment` as an optional field. Best-effort: an
|
||
unresolved label just leaves the neutral title in place. The
|
||
`/checkout/verarbeitung` polling page also patches this into the
|
||
provisional `sessionStorage` snapshot before promoting it, so
|
||
`/bestellbestaetigung` shows the real instrument too, not the neutral
|
||
placeholder.
|
||
- **`app/api/checkout/route.ts`, `provider === 'stripe'` branch**: creates
|
||
a Stripe PaymentIntent *before* the order (`app/lib/payments/
|
||
stripeProvider.ts`) — its id is known immediately and gets persisted as
|
||
the order's own `providerReference` field at creation time, so the
|
||
backend's abandonment-cleanup job can reconcile with Stripe later even
|
||
if nothing else about this flow ever completes. The order is created
|
||
with `status: 'pending_payment'`, `paymentStatus: 'pending'` — no
|
||
invoice number yet, no confirmation email yet (both deferred to the
|
||
webhook-driven confirm-payment step, on the backend). Right after, a
|
||
best-effort (awaited, non-fatal) call attaches `{orderId, orderNumber}`
|
||
as PaymentIntent metadata (`attachOrderMetadata`) — this is what lets
|
||
the webhook resolve an incoming Stripe event back to a specific Payload
|
||
order.
|
||
- **`app/checkout/components/PaymentStep.tsx`** renders in place of the
|
||
address form once `/api/checkout` returns `requiresPayment: true` —
|
||
Stripe's `PaymentElement` (real mode) or a "Testzahlung erfolgreich /
|
||
fehlgeschlagen" button pair (test mode, see below). A card confirms
|
||
in-place; PayPal (and 3-D-Secure challenges) redirect out and back via
|
||
`return_url=/checkout/verarbeitung?orderNumber=...`.
|
||
- **`app/api/webhooks/stripe/route.ts`** — the real inbound webhook.
|
||
Verifies `stripe-signature` against `STRIPE_WEBHOOK_SECRET`, reads the
|
||
**raw** body (never `.json()` — the signature is computed over the exact
|
||
bytes), handles `payment_intent.succeeded`/`.payment_failed`, and calls
|
||
the backend's `POST /api/orders/:id/confirm-payment` (guarded by
|
||
`PAYMENT_WEBHOOK_SECRET`, a secret distinct from `ORDER_SERVICE_SECRET`
|
||
on purpose — least privilege, it can only hit this one action) with
|
||
`{paymentStatus, providerReference, paidAt}`. Returns a non-2xx status on
|
||
any internal failure so Stripe's own retry schedule (~3 days) provides
|
||
resilience for free, rather than this app building its own retry queue.
|
||
That backend endpoint flips the order to `received`, assigns the
|
||
(until-then-deferred) invoice number, and queues the internal admin
|
||
new-order notification — see the backend repo's own README for that
|
||
half. It has no SMTP sender of its own, though: it returns a full order
|
||
snapshot in its response instead, and **this webhook route is what
|
||
actually sends the confirmation email + invoice PDF**
|
||
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
|
||
`alreadyProcessed: true` — a repeat webhook delivery must never resend
|
||
it), mirroring exactly what the checkout route already does inline for
|
||
a manual/Überweisung order. Product photos in that snapshot's
|
||
`items[].imageUrl` need no frontend change to work —
|
||
`ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
|
||
field; the backend just wasn't populating it (fixed there, see its own
|
||
README — needed `depth: 2` so `item.product.image` resolves to a real
|
||
`Media` doc).
|
||
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
|
||
`return_url` target. Neither a client-side `confirmPayment()` success nor
|
||
landing back from a PayPal redirect is trusted as proof of payment on its
|
||
own (a closed tab mid-redirect looks identical to success from here) —
|
||
this page polls `/api/checkout/status?orderNumber=...` (session-scoped,
|
||
so a guessed order number can't be used to probe someone else's payment
|
||
status) until `paymentStatus` flips to `paid`, then promotes the
|
||
provisional `sessionStorage` snapshot (`PENDING_ORDER_KEY`, written right
|
||
before handing off to Stripe) to the real one (`ORDER_KEY`), clears the
|
||
cart, and redirects to `/bestellbestaetigung` — exactly the same
|
||
sessionStorage mechanism Überweisung orders already used, just populated
|
||
a step later. On `failed`/`cancelled` it shows a retry message with the
|
||
cart left intact (never cleared until payment actually succeeds); on a
|
||
slow-to-arrive webhook it times out after ~15s with a "we'll email you"
|
||
message rather than polling forever.
|
||
|
||
**Local testing without a real Stripe account** — `PAYMENT_TEST_MODE`
|
||
(defaults on whenever `STRIPE_SECRET_KEY` is unset, so a fresh `npm run dev`
|
||
never accidentally calls the real Stripe API): `app/lib/payments/index.ts`
|
||
swaps in `mockProvider.ts` instead of `stripeProvider.ts` — same interface,
|
||
so the checkout route and everything downstream of it runs unmodified.
|
||
`PaymentStep.tsx` shows "Testzahlung erfolgreich"/"Testzahlung
|
||
fehlgeschlagen" buttons instead of the real Payment Element; clicking one
|
||
calls `app/api/webhooks/stripe/test-confirm/route.ts`, which skips
|
||
signature verification (there's no real Stripe event to verify) and calls
|
||
the exact same backend `confirm-payment` endpoint the real webhook does —
|
||
so clicking "erfolgreich" exercises the *entire* real pipeline (deferred
|
||
invoice numbering, gated email, idempotency) end to end, it's only the
|
||
Stripe API call itself that's faked. That test-confirm route hard-404s
|
||
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
|
||
a reachable "mark any order paid" endpoint in production.
|
||
|
||
**Activating real Stripe payments — checklist.** Everything code-side is
|
||
already live (both `main` branches deployed); this is purely
|
||
provisioning. Nothing here is required to *test* the flow today —
|
||
`PAYMENT_TEST_MODE` already works end to end with zero Stripe account.
|
||
|
||
Already done, as of 2026-07-25:
|
||
- [x] `PAYMENT_WEBHOOK_SECRET` set in Coolify, matches the backend's copy
|
||
in `docker/.env` — needed even in test mode (the test-confirm route
|
||
sends it as a header the backend checks).
|
||
- [x] `payment-methods` rows configured: Kreditkarte/PayPal →
|
||
`provider: 'stripe'`; "Überweisung (Vorkasse)" → `provider: 'manual'`
|
||
(untouched by anything below); "Sofortüberweisung" prepared as
|
||
`provider: 'stripe'` but `active: false` (needs a logo before switching
|
||
on — see the backend's own README).
|
||
- [x] Database migration applied to production, confirmed live.
|
||
|
||
Still needed, in order:
|
||
1. **Create a Stripe account** (free). Stay in **test mode** first (toggle
|
||
top of the Stripe dashboard) — nothing below moves real money until
|
||
step 5.
|
||
2. **Copy the test API keys** — *Entwicklerbereich → API-Schlüssel*:
|
||
`sk_test_...` and `pk_test_...`.
|
||
3. **Create a webhook endpoint** — *Entwicklerbereich → Webhooks → Endpoint
|
||
hinzufügen*, URL `https://einfach-produktiv.mk360.de/api/webhooks/stripe`,
|
||
events: at minimum `payment_intent.succeeded` and
|
||
`payment_intent.payment_failed`. Copy the signing secret, `whsec_...`.
|
||
4. **Set these three in Coolify** (`einfach-produktiv` app → Environment
|
||
Variables):
|
||
| Variable | Value |
|
||
|---|---|
|
||
| `STRIPE_SECRET_KEY` | `sk_test_...` |
|
||
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_test_...` |
|
||
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` |
|
||
|
||
The moment `STRIPE_SECRET_KEY` is set, `PAYMENT_TEST_MODE` switches off
|
||
automatically (see above) — the real Payment Element replaces the mock
|
||
buttons. Still 100% safe: Stripe's own test mode only accepts test card
|
||
numbers (e.g. `4242 4242 4242 4242`), no real charge is possible.
|
||
5. **Set the same `sk_test_...` in the backend** too —
|
||
`/home/marco/dev/docker/.env`'s `STRIPE_SECRET_KEY` (used only for
|
||
`stripeRefund.ts`, Storno/Gutschrift refunds) — then
|
||
`cd ~/dev/docker && docker compose build payload && docker compose up -d payload`
|
||
to pick it up.
|
||
6. **Redeploy the frontend** so it picks up the new Coolify env vars —
|
||
either wait for the next git push (Coolify redeploys on push) or
|
||
trigger one directly: `curl -X POST https://coolify.mk360.de/deploy/einfach-produktiv`.
|
||
7. **Test end to end**: place a real order with a Stripe test card,
|
||
confirm the order flips to `received`, invoice/confirmation email
|
||
arrive, product images show, `Orders.paymentStatus` reads `paid`. Try a
|
||
declined test card too (e.g. `4000 0000 0000 0002`) and confirm the
|
||
order shows "Zahlung fehlgeschlagen" in the admin, not in the
|
||
customer's own order history.
|
||
8. **Trigger a Storno on a paid test order** in the admin, confirm the
|
||
refund job actually calls Stripe (check the PaymentIntent in the Stripe
|
||
dashboard) and `Orders.refundStatus` updates.
|
||
9. **Go live**: only once ready for real charges — verify the Stripe
|
||
account for live payments (business details), switch the dashboard to
|
||
**live mode**, repeat steps 2–6 with the live-mode keys (`sk_live_...`/
|
||
`pk_live_...`, a *new* webhook endpoint registered in live mode → new
|
||
`whsec_...`) — these replace the test values in both Coolify and
|
||
`docker/.env`, not additional variables.
|
||
|
||
### VAT display
|
||
|
||
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
|
||
resolved rate (`app/lib/cartTotals.ts`'s `effectiveTaxRate(product,
|
||
defaultRate)` — a product's own `taxRatePercent` override if set,
|
||
otherwise the tenant default from `company-settings`), not a generic
|
||
"inkl. MwSt." disclosure — `getDefaultTaxRatePercent()` in
|
||
`app/lib/payload.ts` is a separate, ISR-cached (60s) fetch of just that one
|
||
number, deliberately not `getCompanySettings()` itself (that one is
|
||
`cache: "no-store"` for its invoice-generation callers, where always-fresh
|
||
bank details matter; the display rate only needs the same freshness every
|
||
other public catalog fetch already has).
|
||
|
||
A cart/checkout/order-confirmation *total* additionally shows the actual
|
||
€ amount of VAT included, not just a percentage — `computeTaxBreakdown()`,
|
||
imported from `@einfach-produktiv/invoicing` (a shared package consumed
|
||
by both this repo and the Payload backend as a git dependency — see
|
||
"Invoice PDFs" below; it used to be an independently-duplicated
|
||
`groupByTaxRate()` inside `invoicePdf.tsx`/`correctionInvoicePdf.tsx`,
|
||
both of which have since moved into that package too)
|
||
groups line items by their effective rate and reports each group's actual
|
||
tax amount; `app/components/VatBreakdown.tsx` renders a single "enthält
|
||
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
|
||
when it spans more than one. Used on `/cart`, `/checkout`, `/bestellbestaetigung`,
|
||
the order-confirmation email (`emailTemplates.ts`'s `renderOrderConfirmationHtml`,
|
||
which already carried per-item `taxRatePercent` but didn't render it
|
||
before), and both account order pages (see "Orders & customer accounts"
|
||
below) — the account order list additionally shows up to 4 product
|
||
thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
|
||
a plain product-id → image-url lookup separate from the slug-keyed
|
||
catalog, since an order only ever snapshots a numeric product id).
|
||
|
||
**None of this renders at all for a Kleinunternehmer tenant** (built
|
||
2026-07-24) — see "Kleinunternehmerregelung" below for the full list of
|
||
touched spots and why some read a live setting and others a persisted
|
||
per-order snapshot.
|
||
|
||
**Vorkasse instruction in the confirmation email (2026-07-25)** — the
|
||
invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own
|
||
`unpaidNoticeText`), but a customer often only glances at the email body
|
||
itself, not the attached PDF. `OrderConfirmationData` gained a required
|
||
`isManualPayment: boolean` field — **explicitly set by each caller**
|
||
(checkout route's manual branch: `true`; the Stripe webhook path:
|
||
always `false`, since only a *paid* Stripe order ever reaches that send at
|
||
all), deliberately **not** derived from `paymentMethodTitle` inside
|
||
`emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte",
|
||
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
|
||
payment-methods rename already broke once this session (see
|
||
`isPaidImmediately()` in the invoicing package). When `isManualPayment` is
|
||
true, `vorkasseNotice()` renders a full-width block (own row, `margin-top:
|
||
20px` — not squeezed into the Gesamtsumme table) naming the bank details
|
||
(`CompanySettings.bankName`/`iban`/`bic`, now also exposed on the
|
||
frontend's own `CompanySettings` type — previously only `iban`/`bic` were)
|
||
and the "processed within 1–2 business days after payment received" note.
|
||
|
||
### Checkout state persistence
|
||
|
||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||
plain read/write functions (not `useSyncExternalStore` like `cart.ts`/
|
||
`discount.ts`: `CheckoutContent` is this draft's only reader, no
|
||
cross-component subscription to keep in sync). Every address-card field
|
||
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
|
||
shipping-address-override fields below, newsletter opt-in) plus the
|
||
selected shipping/payment method is now a controlled input backed by this
|
||
draft, restored on mount (a `useEffect`-deferred read, same SSR/hydration-
|
||
mismatch avoidance as `BestellbestaetigungContent`'s own sessionStorage
|
||
read) and cleared on a completed purchase. `password` is deliberately
|
||
excluded — stays a plain uncontrolled, unpersisted input.
|
||
|
||
### Optional deviating shipping address
|
||
|
||
"1. Rechnungsadresse" always collects a plain street address now — no
|
||
Lieferart (Lieferadresse/Packstation) toggle there anymore, since a
|
||
Packstation isn't a valid billing address for an invoice. A separate
|
||
checkbox ("Abweichende Lieferadresse verwenden") reveals a second address
|
||
section with its *own* delivery-method toggle (own name + delivery method +
|
||
street/Packstation/PLZ/Ort/Land) — that's the only place Packstation
|
||
delivery is offered at all. When used, the order's original address
|
||
fields stay the **billing** address (used for the invoice's "An" block
|
||
regardless), and the `shipping*`-prefixed fields (`hasDifferentShippingAddress`,
|
||
`shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/
|
||
`shippingStreet`/`shippingPackstationNumber`/`shippingPostNumber`/
|
||
`shippingZip`/`shippingCity`/`shippingCountry` — mirrored 1:1 on the
|
||
Payload `orders` collection, see the Payload README) determine where the
|
||
order actually ships. `/api/checkout/route.ts` validates the override the
|
||
same way it already validated the primary address (required fields,
|
||
street-xor-Packstation depending on the chosen delivery method). The
|
||
invoice PDF shows a third "Lieferadresse" address block alongside Von/An
|
||
when set (see "Invoice PDFs" below); `/konto/bestellungen/[orderNumber]`
|
||
shows both addresses too, relabeling the first one "Rechnungsadresse"
|
||
instead of "Lieferadresse" only once there's an actual second address to
|
||
distinguish it from.
|
||
|
||
### Destination countries (Payload-configurable)
|
||
|
||
Both country `<select>`s (billing, and the shipping-address override)
|
||
are fed by `getShippingCountries()` (`app/lib/payload.ts`) reading
|
||
Payload's `shipping-countries` collection (`name`, `plzDigits`,
|
||
`active`, `sortOrder`) — not a hardcoded array anymore. `plzDigits` also
|
||
drives PLZ's own `maxLength`/pattern validation (`validateZip()` in
|
||
`CheckoutContent.tsx` builds a `country → digit count` map from this
|
||
list), so an admin adding a country in Payload doesn't need a frontend
|
||
deploy to make it selectable, and the PLZ format check automatically
|
||
matches whatever digit count that country's row specifies. Seeded with
|
||
Deutschland (5 digits) and Österreich (4) — matching what this checkout
|
||
already offered before this became configurable. Adding a country here
|
||
(e.g. Schweiz) makes it immediately selectable at checkout; it does
|
||
**not** by itself add any customs/export-invoice handling or affect VAT
|
||
exemption eligibility (see "VAT exemption" below — that's still a
|
||
separate, deliberately-not-Payload-configurable legal decision).
|
||
|
||
### Product variants
|
||
|
||
A cart line's identity is `(id, variant)` together, not `id` alone —
|
||
`app/lib/cart.ts`'s `CartItem` gained an optional `variant?: string` field
|
||
(the selected `products.variants[].name`), and every function that used to
|
||
match a line by `id` (`addToCart`/`removeFromCart`/`setQuantity`) now
|
||
matches by both via a shared `sameLine()` helper, so two lines for the
|
||
same product with different variants stay genuinely separate entries
|
||
instead of merging or clobbering each other. `variant` undefined on both
|
||
sides (the common no-variants case) still matches by simple equality —
|
||
every pre-existing call site that never passes a variant keeps working
|
||
unchanged.
|
||
|
||
**Where a variant gets picked**: both `AddToCartInlineButton`
|
||
(`/shop`, `/cart`'s related-products grid) and `AddToCartButton` (the
|
||
marketing-page-specific one on `/todo-cards`' Hero + Pricing panel and the
|
||
homepage spotlight) render a `<select>` above the button when their
|
||
`variants` prop is non-empty, defaulting to the first *in-stock* variant.
|
||
All five call sites already fetch the full product server-side
|
||
(`getProducts()`/`getProductBySlug()`/`getSpotlightProduct()`), so
|
||
`product.variants` and `product.outOfStock` are simply passed straight
|
||
through — no separate data-fetch needed for `AddToCartButton`'s two pages.
|
||
|
||
**Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives
|
||
`Product.outOfStock` (and each `variants[].outOfStock`) from
|
||
`trackInventory`/`stock`/`allowBackorder` — true only when inventory is
|
||
tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart
|
||
buttons disable themselves and show "Ausverkauft" for whichever variant is
|
||
currently selected (or the plain product, when there are no variants);
|
||
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing any
|
||
discount/low-stock badge — a sold-out product has nothing else useful to
|
||
show) once *every* variant of a product is out — one sold-out variant
|
||
among several just reads as such in the picker itself, not as a
|
||
misleading blanket badge.
|
||
|
||
**Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives
|
||
`Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/
|
||
`stock`/`lowStockThreshold` the same way `outOfStock` is derived — true
|
||
only when inventory is tracked, stock is above zero (out-of-stock has its
|
||
own distinct badge, the two never combine), and at/below the product's own
|
||
`lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is
|
||
exposed in the public `Product` type, only this derived boolean — the
|
||
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
|
||
verfügbar" **text line** (`text-warning`, the same `--color-warning` token
|
||
in `globals.css`, distinct from the brand-colored discount badge) next to
|
||
the price on `ProductGrid.tsx`/`ProductSpotlight.tsx`/`RelatedProducts.tsx`/
|
||
todo-cards' `TodoKartenHero.tsx` and `Pricing.tsx` (both — the page has two
|
||
independent purchase CTAs, hero and pricing panel, each with their own
|
||
price/delivery block) and — variant-specific, not
|
||
"any variant low" — under the product name in `CartContent.tsx`'s own
|
||
cart line items, plus the existing `"(nur noch wenige)"` variant-select
|
||
suffix in `AddToCartButton`/`AddToCartInlineButton`. The image-overlaid
|
||
top-left pill (`position: absolute`, outside layout flow) now shows only
|
||
Ausverkauft/discount — low-stock moved off the image into text on request,
|
||
since a shopper scanning product photos for "-20%" style badges reads a
|
||
long low-stock sentence pinned to the image as clutter, and a customer
|
||
already in the cart with that product had no low-stock signal there at
|
||
all before this. The earlier version of this text line (removed once,
|
||
see git history) broke equal-height card alignment in `ProductGrid.tsx`/
|
||
`RelatedProducts.tsx` by only rendering when `lowStock` was true, so
|
||
cards with/without the line ended up different heights; this version
|
||
always renders the line's slot (`min-h-[1.05rem]`, empty when not
|
||
low-stock) so every card in a row reserves the same space regardless of
|
||
state — `ProductGrid.tsx` additionally still has its `flex-1` spacer
|
||
pinning the add-to-cart button to the same Y as before, so the reserved
|
||
height is belt-and-suspenders there, but load-bearing in
|
||
`RelatedProducts.tsx`, which has no such spacer. Only "Ausverkauft" still
|
||
wins outright over the discount pill, since it replaces it.
|
||
|
||
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
|
||
a selected variant's `priceOverride` wins over the base `product.price`
|
||
(falling back to it when unset or no variant selected). Every cart/
|
||
checkout/order-confirmation total (`computeSubtotal`, `computeCartTotals`,
|
||
and each page's own per-line price display in `CartContent.tsx`,
|
||
`CheckoutContent.tsx`, `BestellbestaetigungContent.tsx`) goes through this
|
||
instead of reading `product.price` directly. The checkout route
|
||
(`/api/checkout/route.ts`) re-validates the requested variant server-side
|
||
too — same "never trust the client" reasoning as price re-derivation
|
||
generally: a `line.variant` naming something that doesn't exist on that
|
||
product (removed, or a tampered request) fails the whole checkout rather
|
||
than silently falling back to the base price. It also re-checks stock at
|
||
that same point — depth-in-defense, not just the disabled button UI above
|
||
— rejecting the order when the resolved product/variant has
|
||
`trackInventory` on, `allowBackorder` off, and less `stock` than the
|
||
requested quantity.
|
||
|
||
**Snapshotting**: `orders.items[].variantName` captures which variant was
|
||
picked at order time (same "snapshot, not a live relationship" reasoning
|
||
as `bundleContents`) — shown as a parenthetical next to the product name
|
||
on the order-confirmation email, both invoice PDF types, and the
|
||
order-detail page. The server-side cart mirror
|
||
(`Customers.cart[].variantName`, synced via `/api/account/cart`) carries
|
||
the same field so a variant selection survives a login/logout cycle, not
|
||
just the current session.
|
||
|
||
See the Payload README's "Inventory & product variants" section for the
|
||
backend data model (`products.variants`, stock bookkeeping) this all
|
||
builds on.
|
||
|
||
### Tracking numbers
|
||
|
||
`orders.carrier`/`trackingNumber` (admin-entered in Payload, no carrier
|
||
API) show as a clickable link on `/konto/bestellungen/[orderNumber]` when
|
||
set — `app/lib/tracking.ts`'s `buildTrackingUrl(carrier, trackingNumber)`
|
||
mirrors the Payload backend's own copy of this file byte-for-byte close
|
||
(same carrier set/URL patterns, kept in sync by hand, no shared package
|
||
between the two deployments) so the link an admin sees generated in the
|
||
`order-shipped` email matches exactly what a customer sees here. Falls
|
||
back to plain (non-linked) text for `carrier: 'other'`, which has no known
|
||
URL pattern.
|
||
|
||
### Product bundles & per-product tax rates
|
||
|
||
Both resolved server-side in `/api/checkout/route.ts`, at the same point
|
||
prices are already being re-derived from live Payload data (never trusted
|
||
from the client):
|
||
|
||
- **Tax rate**: `product.taxRatePercent ?? companySettings.taxRatePercent`
|
||
— a product's own override if set, otherwise the tenant-wide default
|
||
from `company-settings` (fetched alongside the product catalog,
|
||
`Promise.all([fetchProductsBySlug(), getCompanySettings()])`). Snapshotted
|
||
onto `orders.items[].taxRatePercent` at order creation — see the Payload
|
||
README's "Per-product tax rates" section for why this has to be a
|
||
snapshot, not a live lookup.
|
||
- **Bundles**: `describeBundleContents()` resolves a product's
|
||
`bundleItems` (Payload relationship, populated via `fetchProductsBySlug()`'s
|
||
`depth: 2` fetch — one level deeper than the `depth: 1` `image` alone
|
||
needs, since `bundleItems.product` is a relationship nested inside an
|
||
array field) into a plain string like `"2× ToDo-Karten, 1× Wochenplaner"`,
|
||
snapshotted onto `orders.items[].bundleContents`. A bundle is otherwise
|
||
just a regular product everywhere else in this app — same cart/checkout/
|
||
pricing code path, no special-casing needed, since it's just a product
|
||
with an extra field (see the Payload README's "Product bundles" section
|
||
for why it's modeled that way instead of a separate collection).
|
||
|
||
## Invoice PDFs
|
||
|
||
Generated **synchronously at checkout** and attached to the order
|
||
confirmation email — not just an on-demand download — per an explicit
|
||
product decision that a customer should always have the invoice in their
|
||
inbox, not only in `/konto/bestellungen`.
|
||
|
||
- **`@einfach-produktiv/invoicing`** — a small standalone package
|
||
(`git.mk360.de/Marco/einfach-produktiv-invoicing`, public repo, no
|
||
secrets in it) holding every invoice/correction-invoice renderer plus
|
||
`computeTaxBreakdown()` (see "VAT display" above) and shared
|
||
formatters, consumed here **and** by the Payload backend as a git
|
||
dependency (`"@einfach-produktiv/invoicing":
|
||
"git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"`
|
||
in `package.json`). Ships raw TS/TSX source, no build step of its own —
|
||
this app's `next.config.ts` lists it under `transpilePackages` so this
|
||
app's own bundler compiles it, same as first-party code, the same
|
||
pattern a monorepo tool like Turborepo uses for internal packages
|
||
without actually needing a monorepo. Before this package existed
|
||
(2026-07-23), the correction-invoice renderer was hand-duplicated
|
||
between this repo and the backend, "kept in sync by eye" — that already
|
||
caused three real, customer-visible drifts (a silently dropped
|
||
`variantName`, a footer that wasn't `position: fixed`, a numeric vs.
|
||
spelled-out date format) before the dedup, see that package's own
|
||
README for the specifics.
|
||
- **`InvoiceDocument`** — a `@react-pdf/renderer` `Document`, not
|
||
HTML-to-PDF or a headless browser (Puppeteer/Chromium would be a
|
||
heavier footprint on a VPS already running several other containers).
|
||
Built-in Helvetica rather than a registered web font — this renders
|
||
inside a fire-and-forget checkout step, and a font-fetch failure there
|
||
would be one more way to silently lose the attachment for no real
|
||
design benefit; brand color/spacing still carries the visual identity
|
||
via `StyleSheet`.
|
||
- **Layout**: a header separated by a bold brand-colored rule (not a
|
||
filled color band — a plain line reads cleaner than a solid block of
|
||
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
|
||
addresses, invoice number/date/order-reference/USt-IdNr. shown as small
|
||
bordered "meta boxes" rather than a plain text row, a rounded/bordered
|
||
item table, and a shaded summary card for the totals — deliberately
|
||
closer to the site's own card-based UI language than a generic invoice
|
||
template. The footer is pinned to the bottom of the page
|
||
(`position: absolute` + react-pdf's `fixed` prop), not just wherever the
|
||
content flow happens to end. Item rows share one uniform tinted
|
||
background now (fixed 2026-07-23) — no more alternating white/tinted
|
||
zebra striping.
|
||
- **"Bereits beglichen" confirmation**: shown next to the summary card
|
||
whenever `order.paymentMethodTitle` is anything other than
|
||
`"Überweisung"` (bank transfer) — Kreditkarte and PayPal both settle at
|
||
checkout, so the invoice says so explicitly (`isPaidImmediately()`, in
|
||
the shared package's `invoicePdf.tsx` — "Überweisung" is the one method
|
||
named explicitly as the exception, rather than hardcoding a list of
|
||
"immediate" titles that would need updating every time a new payment
|
||
method is added in Payload). Plain green text, not a tinted pill/badge
|
||
box (fixed 2026-07-23 — a colored background read as too heavy for what's
|
||
just a status note). This one genuinely is conditional on the order's
|
||
own payment method — unlike bank details below, which just used to be
|
||
worded as if it were.
|
||
- **Bank details**: `company-settings.bankName`/`.iban`/`.bic` — `iban`/
|
||
`bic` structured and independently format-validated (uppercased/trimmed
|
||
on save too, so "de123..." doesn't fail validation just for being
|
||
lowercase — same normalization `discount-codes.code` already used),
|
||
`bankName` stays free text since there's no fixed format to validate a
|
||
bank's display name against. Not a single free-text `bankDetails`
|
||
textarea anymore, as of the 2026-07-23 e-invoicing migration's Phase 2 —
|
||
EN16931 wants discrete PaymentMeans data. When `iban` or `bic` is set,
|
||
the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" —
|
||
**always**, regardless of the order's payment method. It used to say
|
||
"Bankverbindung (für Überweisung): …",
|
||
which read as conditional on paying by bank transfer specifically, but
|
||
never actually was (the display was only ever gated on whether the
|
||
field was filled in) — the misleading wording got fixed instead of
|
||
adding the behavior it implied, since a card/PayPal customer might
|
||
still want the seller's bank details for other reasons (e.g. a refund).
|
||
- **Summary layout — a genuinely additive chain (fixed 2026-07-23).** The
|
||
summary card now reads Zwischensumme → Rabatt (if any) → Versand → a
|
||
divider → Gesamt, using the order's own raw `subtotal`/`discountAmount`/
|
||
`shippingCost`/`total` fields directly — every row above the divider
|
||
actually sums to the number below it. A previous version showed
|
||
"Netto"/"zzgl. X% MwSt." rows computed via `computeTaxBreakdown()`,
|
||
which distributes discount/shipping proportionally across each tax-rate
|
||
group *before* computing net/tax (correct for the tax math itself, since
|
||
ancillary costs are legally apportioned across rates) — but the same
|
||
layout **also** printed Rabatt/Versand as their own separate rows on top,
|
||
so the visible rows never actually summed to the printed Gesamt (off by
|
||
exactly the shipping/discount amount — caught against a real production
|
||
order, `#EP-0006-ZDX6`'s Stornorechnung, where the gap was concrete and
|
||
reproducible, not a rounding nit). The per-rate breakdown still exists,
|
||
just relocated **below** Gesamt as an "enthält X% MwSt.: Y €" annotation
|
||
(one line per distinct rate, informational — not part of the additive
|
||
stack above it), the same "contained within, not an extra deduction"
|
||
framing `VatBreakdown.tsx` already used on `/cart`/`/checkout` (see "VAT
|
||
display" above). Applies to both the original invoice and its Storno/
|
||
Gutschrift, which has its own equivalent chain (Zwischensumme → the
|
||
discount reversal, "Rabatt (entfällt)", a positive add-back since the
|
||
original discount no longer applies once everything's undone → Versand →
|
||
Gesamt; a Gutschrift shows Gesamt alone, since it never reverses
|
||
shipping/discount in the first place — see the Payload README's "How a
|
||
Stornorechnung/Gutschrift relates to the original invoice"). When the
|
||
order is VAT-exempt (see "VAT exemption" below), this annotation becomes
|
||
"Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead —
|
||
"enthält 0% MwSt.: 0,00 €" would be a meaningless thing to print.
|
||
- **Netto row (added 2026-07-23)**: a dedicated "Netto" row sits between
|
||
Gesamt and the "enthält X% MwSt." annotation on every invoice this shop
|
||
issues (original, Storno, Gutschrift alike) — businesses read this
|
||
directly for their own input-tax deduction instead of computing Gesamt
|
||
minus MwSt by hand. Shown unconditionally, including on VAT-exempt
|
||
orders (net and Gesamt happen to be the same figure there — a first
|
||
version skipped the row in that case, corrected same day since the ask
|
||
was for every invoice, not conditional on the tax rate).
|
||
- **Product thumbnails**: each item row shows a small product image —
|
||
resolved from the order-confirmation data's already-available
|
||
`imageUrl` for the checkout-time attachment, or via
|
||
`getProductImagesByIds()` (see "VAT display" above) for the on-demand
|
||
re-download route, since a stored order item only snapshots a numeric
|
||
product id, not an image URL.
|
||
- **Bundle contents**: an item row for a bundle product also shows the
|
||
small muted `bundleContents` sub-line snapshotted at order time (see
|
||
the Payload README's "Product bundles" section).
|
||
- **Shipping address**: when `order.hasDifferentShippingAddress` is set
|
||
(see "Optional deviating shipping address" above), a third
|
||
"Lieferadresse" address block joins Von/An (three ~30%-width columns
|
||
instead of two ~45%-width ones) — otherwise unchanged, two columns as
|
||
before. `USt-IdNr.` no longer repeats in a header meta box — it already
|
||
lives in the footer, printing it twice was redundant.
|
||
- **Buyer B2B fields**: when the order has a `companyName`/`vatId` (see
|
||
"B2B checkout fields" below), the "An" block shows `companyName` as its
|
||
own line above the contact person's name, and `vatId` as its own line
|
||
below the address (labelled "USt-IdNr. …") — the buyer-side counterpart
|
||
to the seller's own VAT ID already shown in the footer.
|
||
- **`app/lib/invoiceData.ts`** (still local to this repo — a thin
|
||
server-only wrapper, not part of the shared package) — `generateInvoicePdf(order, seller)` /
|
||
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
|
||
entrypoints every caller below goes through; both just call straight
|
||
into `@einfach-produktiv/invoicing`'s `renderInvoicePdf()`/
|
||
`renderCorrectionInvoicePdf()`. `seller` (`company-settings`
|
||
data) is passed in rather than fetched inside these functions, so a
|
||
caller that also needs it for something else in the same request (e.g.
|
||
`orderEmail.ts`'s legal email footer, see "Legal footer (Anbieterkennzeichnung) on every email" below)
|
||
fetches it once via `getSellerForInvoice()`, not twice.
|
||
- **Original invoice — called from two places, same render function:**
|
||
`app/lib/orderEmail.ts` (checkout attachment — a PDF-generation failure
|
||
here does **not** sink the confirmation email itself, it just sends
|
||
without the attachment and alerts admin) and
|
||
`app/api/account/orders/[orderNumber]/invoice/route.ts` (GET, customer's
|
||
own order only, "Rechnung herunterladen" on
|
||
`/konto/bestellungen/[orderNumber]`) — a re-download always matches what
|
||
was originally emailed, since `invoiceNumber`/`invoiceIssuedAt` are
|
||
assigned exactly once, server-side, at order creation (Payload's
|
||
`orders.ts` `beforeChange` hook — see the Payload README) and never
|
||
regenerated.
|
||
- **Correction invoice (Stornorechnung/Gutschrift) — same "no file
|
||
storage" approach.** The *real* document is generated once, Payload-side,
|
||
the moment an order reaches `cancelled`/`returned` (see the Payload
|
||
README's "How a Stornorechnung/Gutschrift relates to the original
|
||
invoice" section for the full legal/mechanical reasoning) and attached
|
||
to that status email. This repo's own `/konto/bestellungen/[orderNumber]`
|
||
"Stornorechnung/Gutschrift herunterladen" button
|
||
(`app/api/account/orders/[orderNumber]/correction-invoice/route.ts`)
|
||
calls the exact same `renderCorrectionInvoicePdf()` from
|
||
`@einfach-produktiv/invoicing` the backend used to generate the
|
||
original — not a ported approximation anymore (that used to be a
|
||
separate, hand-duplicated copy; see the shared package's README) — so a
|
||
re-download is now structurally guaranteed to match what was emailed,
|
||
not just guaranteed by careful manual syncing. No PDF is ever persisted
|
||
to disk/S3/Media: `correctionInvoiceNumber`/`correctionInvoiceIssuedAt`
|
||
are immutable once set (Payload's `beforeChange` hook), so re-rendering
|
||
from the order's own stored data always reproduces the identical
|
||
document — the underlying data is already durable in Postgres, and
|
||
deterministic regeneration needs no cleanup or storage cost, same
|
||
reasoning already applied to the original invoice. Also has product
|
||
thumbnails (same resolution approach as the original invoice) and, for a
|
||
Stornorechnung specifically, an explicit "Versand" summary line — it was
|
||
previously only folded silently into the tax-rate groups' scaled gross
|
||
amounts, with no line stating how much of the reversed total was
|
||
shipping. A Gutschrift never shows this line, since it never reverses
|
||
shipping in the first place (see the reasoning below).
|
||
- **Numbering**: `invoiceNumber` and `correctionInvoiceNumber` each come
|
||
from their own gapless counter on Payload's `number-ranges` collection
|
||
(`invoicePrefix`/`Next`/`Padding` vs. `correctionInvoicePrefix`/`Next`/
|
||
`Padding` — a Stornorechnung/Gutschrift used to draw from the *same*
|
||
counter as ordinary invoices; split into its own sequence as of
|
||
2026-07-23). Both are assigned via a single atomic `UPDATE ...
|
||
RETURNING` against Postgres (Payload's backend `numberRange.ts`), not a
|
||
read-then-write across two separate calls — see the Payload README's
|
||
"Number ranges" section for why that distinction actually matters for
|
||
§14 UStG.
|
||
- **`company-settings`** (Payload collection, structured seller data —
|
||
name/address/`vatId`/`taxRatePercent`/`bankName`/`iban`/`bic`) is fetched via
|
||
`getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
|
||
way as order creation (`x-order-service-secret` header,
|
||
`ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details)
|
||
but does need to be reachable from this app's own server-side code, not
|
||
just from inside Payload's admin. **Currently seeded with placeholder
|
||
data** ("Björn Wendt", "Musterstraße 12", USt-IdNr. "DE123456789", a
|
||
placeholder IBAN/BIC) mirroring the Impressum's own placeholder content
|
||
— real business details need to be entered in the Payload admin before
|
||
an invoice generated from this is legally valid. `taxRatePercent` is
|
||
deliberately a configurable admin field, not a hardcoded `19` in the
|
||
renderer, per an explicit decision to keep the VAT rate editable without
|
||
a code change.
|
||
- §14 UStG line items: seller/buyer address, invoice number + date, order
|
||
reference, per-item quantity/price, net subtotal per rate, tax rate +
|
||
amount per rate, gross total — all on the PDF, not just the summary the
|
||
confirmation email's HTML already shows.
|
||
- **E-invoicing (ZUGFeRD/EN16931) migration — done, live in production
|
||
since 2026-07-23.** Every invoice generated above is actually a
|
||
Factur-X-EN16931 hybrid PDF/A-3 (the same visual PDF, plus an embedded
|
||
machine-readable `factur-x.xml`), for every order, not just B2B — see the
|
||
shared package's own README for the full phased build (`@e-invoice-eu/core`,
|
||
atomic invoice numbering, a self-hosted Mustang-CLI CI pipeline that
|
||
validates every push to that package against real EN16931/PDF-A-3
|
||
conformance rules).
|
||
|
||
### B2B checkout fields
|
||
|
||
Split out from the e-invoicing migration, built 2026-07-23 once that
|
||
shipped. Optional "Firma"/"USt-IdNr." fields sit right under Vorname/
|
||
Nachname in `/checkout`'s Card 1 — neither is required just because the
|
||
other is filled in (a sole proprietor might give a VAT ID with no separate
|
||
company name, and vice versa). Format-validated client- and server-side
|
||
(`app/lib/vatId.ts`'s `isValidVatId()`/`normalizeVatId()`, same EU-format
|
||
regex as `company-settings.vatId` on the backend), persisted through
|
||
`checkoutDraft.ts` like every other Card 1 field, and saved as a
|
||
`Customers` profile default (`/konto/profil`) that prefills future
|
||
checkouts — `Orders` keeps its own independent snapshot regardless, same
|
||
"a later profile edit must never rewrite what an order actually said"
|
||
reasoning as every other snapshot field. Shown on the invoice PDF's "An"
|
||
block (see "Invoice PDFs" above) and threaded through both invoice
|
||
download routes and the correction-invoice email.
|
||
|
||
### VAT exemption (innergemeinschaftliche Lieferung)
|
||
|
||
Built 2026-07-23. A cross-border EU B2B sale — Österreich is the
|
||
eligible destination (`isExemptionEligibleCountry()` in
|
||
`app/lib/vatExemption.ts`, hardcoded, deliberately **not** read from the
|
||
Payload-configurable `shipping-countries` list above — eligibility is a
|
||
legal decision, not a shipping-logistics one, so an admin adding a new
|
||
destination country can't accidentally also grant it a VAT exemption) —
|
||
to a buyer whose VAT ID a live lookup against the EU's public VIES
|
||
service actually confirms is registered gets zero-rated per §4 Nr. 1b
|
||
UStG. Deliberately *not* based on format-validity alone: an unverified
|
||
VAT ID zero-rating an invoice is a real compliance risk (if it later
|
||
turns out unregistered, the seller retroactively owes the VAT itself).
|
||
|
||
- **`app/lib/vies.ts`** — calls the European Commission's public VIES REST
|
||
API (`POST .../check-vat-number`, confirmed live 2026-07-23) directly,
|
||
server-side only.
|
||
- **`app/lib/vatExemption.ts`** — `destinationCountry()` picks the actual
|
||
place the goods ship to (the shipping-address override's country when
|
||
set, billing country otherwise — the exemption depends on where the
|
||
goods physically move, not necessarily the invoice address);
|
||
`computeExemptTotals()` de-grosses every item's price and the shipping
|
||
cost from their normal VAT-inclusive catalog figures to net, since the
|
||
whole point of the exemption is that the buyer pays less, not that this
|
||
shop quietly keeps the VAT portion as extra margin.
|
||
- **VAT-ID *validity* and the exemption *decision* are two separate
|
||
questions (fixed 2026-07-23)** — a first version only ever called VIES
|
||
when the destination already qualified for the exemption (Österreich),
|
||
so a garbage VAT ID on a domestic order (e.g. `"ED123456789"` — not even
|
||
a real country code) sailed through with just a format check, and a
|
||
genuinely valid German/Swiss VAT ID got no confirmation either. Now
|
||
`app/api/checkout/validate-vat/route.ts`/`app/api/checkout/route.ts`
|
||
check any format-valid VAT ID against VIES regardless of destination
|
||
(data quality — worth knowing whether it's real at all, same reasoning
|
||
as `company-settings.vatId`'s own check below) — the exemption itself
|
||
still only applies when the destination is *also* Österreich. A
|
||
validated German VAT ID never zero-rates a domestic sale, no matter how
|
||
real it is.
|
||
- **Checkout UX**: the USt-IdNr. field's blur gives instant feedback for
|
||
any country — "✓ USt-IdNr. bestätigt" (plus "— Lieferung wird steuerfrei
|
||
berechnet." only when the destination actually qualifies) flips the
|
||
sidebar total to the exempt (de-grossed) figures live, as a preview.
|
||
`app/api/checkout/route.ts` re-runs the exact same VIES check server-side
|
||
at submit time regardless, as the actual source of truth — the
|
||
client-side result is never trusted. **VIES being unreachable fails
|
||
closed on the exemption**: normal VAT applies, never a guessed exemption
|
||
(contrast the Payload backend's own `company-settings.vatId` VIES check,
|
||
which fails open, since that one only needs to catch an admin's
|
||
data-entry typo, not decide a tax rate). On an unconfirmed VAT ID, the
|
||
field re-focuses so the customer's attention returns there — it no
|
||
longer also selects the whole existing value (dropped 2026-07-23; a
|
||
stray keystroke while just glancing at the error shouldn't wipe out
|
||
what was already typed). This same "re-focus the failing field"
|
||
behavior now applies generically to *any* checkout field that fails
|
||
its own blur validation, not just this one — see the bullet below.
|
||
- **Every other checkout field is now blur-validated too** (fixed
|
||
2026-07-23, alongside this feature) — inline red error text appears the
|
||
moment a field loses focus (required fields, email format, PLZ digit
|
||
count per country, Packstation/Postnummer digit count), not only when
|
||
the browser's native `pattern`/`required` validation kicks in at submit.
|
||
The native attributes stay in place as a fallback for any field somehow
|
||
never blurred (e.g. autofill). `setFieldError(name, message, refocusEl?)`
|
||
(`CheckoutContent.tsx`) takes an optional element to refocus whenever the
|
||
message is non-empty — every field's own `onBlur` passes `e.target`, so
|
||
a field that fails validation gets focus put right back on it
|
||
generically, not just the USt-IdNr. special case above.
|
||
- **Persistence**: `Orders.vatExempt`/`vatIdValidatedAt` (Payload backend)
|
||
record the outcome, decided once server-side, never editable in the
|
||
admin. `vatIdValidatedAt` is set for *any* VIES-confirmed VAT ID
|
||
(data-quality audit trail), independently of whether `vatExempt` is also
|
||
true — see the Payload README's "B2B checkout & VAT exemption" section
|
||
for the full field/audit-trail reasoning and the invoice PDF/EN16931 XML
|
||
side of this feature.
|
||
- **Every fixed-length numeric field now hard-caps input length too**
|
||
(fixed 2026-07-23) — PLZ (`maxLength` = the selected country's own digit
|
||
count), USt-IdNr. (`14`), Packstationnummer (`3`)/Postnummer (`10`, on
|
||
the backend) all already had `pattern`/`validate` format checks, but
|
||
nothing stopped the browser from accepting more characters than could
|
||
ever pass. Same "as simple/state-of-the-art as possible" input-quality
|
||
pass as the blur-validation fix above.
|
||
- **`/bestellbestaetigung`** mirrors the same exempt-totals branch from the
|
||
persisted `OrderSnapshot.vatExempt` flag (it otherwise re-derives totals
|
||
live from the current catalog, which would show the wrong, VAT-inclusive
|
||
figures for an exempt order).
|
||
|
||
### Kleinunternehmerregelung (§19 UStG)
|
||
|
||
Built 2026-07-24. `company-settings.kleinunternehmer` — a standing
|
||
per-tenant setting (Payload backend), not a per-order decision like the
|
||
VAT exemption above — when on, this tenant never charges VAT on anything,
|
||
domestic or cross-border. See the Payload README's own
|
||
"Kleinunternehmerregelung" section for the field/collection side; this one
|
||
covers what changed here.
|
||
|
||
- **`api/checkout/route.ts`** forces every item's `taxRatePercent` to `0`
|
||
when `getCompanySettings().kleinunternehmer` is on — deliberately
|
||
**without** de-grossing `unitPrice` the way `vatExempt` above does
|
||
(that exemption zero-rates what would otherwise be a positive-rate
|
||
charge, so de-grossing means the buyer pays less; a Kleinunternehmer
|
||
never charged VAT on the sale to begin with, so the catalog gross price
|
||
already *is* the actual net charge — confirmed with the user as the
|
||
intended business decision, not an engineering default). The VIES
|
||
lookup/`isExemptionEligibleCountry()` check is skipped entirely in this
|
||
branch too — there's no VAT for the intra-community rule to exempt
|
||
either. Snapshotted onto the new order as `Orders.kleinunternehmer`
|
||
(mirrors `vatExempt`'s own snapshot reasoning — see the Payload README).
|
||
- **Storefront "inkl. X% MwSt." hints** — four spots read the *live*
|
||
setting (`getKleinunternehmer()` in `app/lib/payload.ts`, same ISR-cached
|
||
60s freshness as `getDefaultTaxRatePercent()`) and drop the MwSt. clause
|
||
entirely when it's on, since there's no order yet at that point to
|
||
snapshot from: `ProductGrid.tsx` (shop grid), `RelatedProducts.tsx`
|
||
(cart's upsell row), `Pricing.tsx`/`TodoKartenHero.tsx` (ToDo-Karten
|
||
landing page), `ProductSpotlight.tsx` (homepage). `Pricing.tsx`/
|
||
`ProductSpotlight.tsx` keep "zzgl. Versand" on its own when the MwSt.
|
||
clause drops; the other two had no such trailing clause to preserve.
|
||
- **Every already-placed-order display reads the persisted snapshot
|
||
instead** — `OrderSnapshot.kleinunternehmer` (`app/lib/order.ts`,
|
||
written into `sessionStorage` at checkout, read by
|
||
`BestellbestaetigungContent.tsx`) and `CustomerOrderDetail.
|
||
kleinunternehmer` (`app/lib/customerAuth.ts`, read by
|
||
`/konto/bestellungen/[orderNumber]`) — never the live company-settings
|
||
value, for the identical "don't retroactively rewrite an already-issued
|
||
invoice's tax treatment" reason `vatExempt` already established. Both
|
||
pages replace the per-item "inkl. X% MwSt." hint and the `VatBreakdown`
|
||
summary with "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." —
|
||
taking precedence over the `vatExempt` note wherever both would
|
||
otherwise apply. `CheckoutContent.tsx`'s live VIES-exemption *preview*
|
||
is also gated off (`!kleinunternehmer && ...`) so a Kleinunternehmer
|
||
tenant never shows a misleading "wird steuerfrei berechnet" preview for
|
||
VAT that was never going to be charged either way.
|
||
- **On-demand invoice/Stornorechnung/Gutschrift downloads**
|
||
(`api/account/orders/[orderNumber]/invoice/route.ts` and its
|
||
`correction-invoice` sibling) thread `order.kleinunternehmer` through to
|
||
`@einfach-produktiv/invoicing`'s renderers the same way they already
|
||
thread `vatExempt`.
|
||
- **`app/company-settings-preview`'s Live Preview** merges the live-edited
|
||
`kleinunternehmer` checkbox onto the fixed `SAMPLE_INVOICE_ORDER` before
|
||
rendering (`kleinunternehmer` lives on `InvoiceOrder`, not
|
||
`InvoiceSeller` — see the invoicing package's own README on why), so an
|
||
admin sees the §19 UStG notice appear/disappear live as they toggle the
|
||
field, without this preview needing its own separate mechanism.
|
||
|
||
### Company Settings & Live Preview
|
||
|
||
`company-settings` has a Live Preview button too, like `email-templates`
|
||
— but instead of an HTML page, it's a **live, in-browser rendered PDF**:
|
||
opening the document in the Payload admin shows the actual invoice layout
|
||
updating as the admin edits `sellerName`/address/`taxRatePercent`/
|
||
`bankName`/`iban`/`bic`, no save required.
|
||
|
||
- **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`**
|
||
— same entrypoint pattern as `/email-preview/[type]` (Draft Mode via
|
||
`app/api/preview/route.ts`), but no `[type]` segment — there's only one
|
||
kind of document here, unlike the 6 email types. **Actually gated on
|
||
`draftMode().isEnabled`** (calls `notFound()` otherwise), unlike
|
||
`/email-preview` — this data includes a real bank IBAN/address once
|
||
filled in, not just marketing email copy, so it must not render for an
|
||
unauthenticated visitor who happens to find the URL.
|
||
- **`@react-pdf/renderer`'s `<PDFViewer>`** (not `renderToBuffer()`) is
|
||
what makes this a *live* preview rather than a static download — it's a
|
||
browser-only component that renders a `Document` straight into an
|
||
embedded PDF viewer `<iframe>`, re-rendering whenever its props change.
|
||
Paired with `useLivePreview()`'s live `data` (same postMessage mechanism
|
||
as the email templates preview), editing a field in the admin re-renders
|
||
the actual PDF in real time — no server round-trip per keystroke.
|
||
Dynamically imported with `{ ssr: false }` (`next/dynamic`) since it
|
||
touches the DOM directly; the HTML-string email previews elsewhere don't
|
||
need that since they're just `dangerouslySetInnerHTML`.
|
||
- Renders `InvoiceDocument` (exported from `@einfach-produktiv/invoicing`
|
||
specifically for this — everywhere else only the async
|
||
`renderInvoicePdf()` buffer-generator is used) against a fixed
|
||
`SAMPLE_INVOICE_ORDER` (also exported from that same package) — same "no
|
||
real document to preview against generically" reasoning as
|
||
`email-templates`' own `SAMPLE_ORDER`.
|
||
- No draft/published distinction here, unlike `email-templates`:
|
||
`company-settings` has no content-versioning concept, it's just the
|
||
current row — the page's initial (pre-postMessage) fetch is the same
|
||
live data `getCompanySettings()` always returns.
|
||
|
||
## Newsletter signup & Brevo sync
|
||
|
||
Built 2026-07-23. Four separate signup entry points across the site —
|
||
the shared `Newsletter` panel (`app/components/Newsletter.tsx`, reused on
|
||
Home and `/newsletter`), `NewsletterModal.tsx` (the Navbar's "Newsletter"
|
||
CTA), `/newsletter`'s own inline hero form
|
||
(`app/newsletter/components/WeeklyImpulsesHero.tsx`), and `/challenge`'s
|
||
`EmailCapture` (`app/challenge/components/EmailCapture.tsx`) — plus
|
||
checkout's existing `newsletterOptIn` checkbox, all sync to Brevo's
|
||
contact list. **Three of the four standalone forms were completely
|
||
non-functional before this** (static markup, no `onSubmit` at all —
|
||
discovered while wiring this up, not a regression) and the fourth
|
||
(`NewsletterModal`) plus checkout's checkbox captured data that just sat
|
||
unsynced.
|
||
|
||
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
|
||
`upsertNewsletterContact(email, source)` calls Brevo's
|
||
**double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation`
|
||
(switched 2026-07-25 from the plain `POST /v3/contacts` single-opt-in
|
||
upsert this originally shipped with) — this only ever *requests* a
|
||
subscription; Brevo sends a confirmation email (the template at
|
||
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in
|
||
template in Brevo's own UI) and only actually adds the contact to
|
||
`BREVO_LIST_ID` once they click through. `source` (`"checkout"` |
|
||
`"newsletter-page"` | `"newsletter-modal"` | `"newsletter-hero"` |
|
||
`"challenge"`) is stored as the contact's `OPT_IN_SOURCE` attribute for
|
||
segmentation — that attribute has to already exist on the Brevo account
|
||
(`POST /v3/contacts/attributes/normal/OPT_IN_SOURCE`) or Brevo silently
|
||
drops it on every request (no error at all, just never stored) rather
|
||
than rejecting the request. `redirectionUrl` (where Brevo sends the
|
||
contact after they click confirm) defaults to `/newsletter-confirmed`
|
||
via `BREVO_DOI_REDIRECT_URL` — a static confirmation page
|
||
(`app/newsletter-confirmed/page.tsx`), same visual language as
|
||
`/bestellbestaetigung` (brand-tinted circular checkmark, serif display
|
||
heading, thin brand divider). No query params to read — Brevo's
|
||
redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
|
||
which polls actual payment status.
|
||
- **Already-subscribed detection (2026-07-25)** — `doubleOptinConfirmation`
|
||
itself gives no way to tell a brand-new signup apart from an
|
||
already-confirmed contact re-submitting the form (verified directly:
|
||
calling it twice for the same confirmed contact returns the identical
|
||
`201` both times, just silently resends the confirmation mail). So
|
||
`upsertNewsletterContact()` checks first via `GET /v3/contacts/{email}`
|
||
— a contact's `listIds` on Brevo is only populated once double opt-in
|
||
actually confirms, never for a merely-requested one, so its presence is
|
||
a reliable signal. If already subscribed: skips the resend entirely and
|
||
returns `{ ok: true, alreadySubscribed: true }` instead. The check fails
|
||
open (any error → proceed to the normal signup flow) — it's a UX
|
||
nicety, never a reason to block a real signup. **Routed through the
|
||
existing error state, not a success variant** — per explicit feedback,
|
||
swapping the whole form out for a bare message (the real-success
|
||
treatment) felt wrong for "you're already signed up, nothing to do"; the
|
||
form stays visible with a small red note below it instead, exactly like
|
||
every other inline validation error (`useNewsletterSignup.ts` sets
|
||
`status: "error"`, `error: "Diese E-Mail-Adresse ist schon für unseren
|
||
Newsletter angemeldet."` — no new UI needed in any of the 4 forms, they
|
||
already render `{status === "error" && <p className="text-red-600
|
||
...">{error}</p>}`).
|
||
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
|
||
state + on-blur validation + refocus-on-invalid-submit behind all four
|
||
forms (same "state of the art, simple" input-quality bar as checkout's
|
||
own fields). Each form keeps its own markup/visual style (`Newsletter`'s
|
||
panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
|
||
the logic is shared, not a one-size-fits-all component. The real-success
|
||
message text also now lives here (`successMessage`) instead of
|
||
hardcoded 4 times per form.
|
||
- **A misconfigured `BREVO_LIST_ID` in Coolify (`2` instead of `5`) broke
|
||
every newsletter signup in production for a stretch of time** —
|
||
discovered and fixed 2026-07-25 while testing the already-subscribed
|
||
feature above. The generic customer-facing error message ("Anmeldung ist
|
||
fehlgeschlagen...") gave no hint why; `upsertNewsletterContact()`'s real
|
||
`reason` was silently discarded by `/api/newsletter/subscribe/route.ts`
|
||
before this, now `console.error`'d server-side (message still stays
|
||
generic to the customer — never leak Brevo's internal error text, just
|
||
no longer *undiagnosable*).
|
||
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
|
||
the single plain-email-format check shared by every newsletter form
|
||
*and* checkout's own email field (previously duplicated between
|
||
`CheckoutContent.tsx` and the subscribe route).
|
||
- **`app/api/newsletter/subscribe/route.ts`** — validates
|
||
email-format + consent server-side too (never trusts the client alone),
|
||
then calls `upsertNewsletterContact()`.
|
||
- Checkout's sync (`app/api/checkout/route.ts`) is fire-and-forget
|
||
alongside the order-confirmation email — a failed marketing sync must
|
||
never fail checkout, and isn't worth a critical alert either (nothing
|
||
customer-facing depends on it).
|
||
- **This app never sends marketing/campaign mail itself** — only
|
||
transactional (order confirmation, password reset, status updates).
|
||
Whatever automation Brevo has configured on the list (a "Welcome Flow"
|
||
etc.) runs entirely on Brevo's own side once a contact lands there;
|
||
Brevo's Automation workflows aren't exposed via their public REST API
|
||
at all, so that piece can only be built/inspected in Brevo's own UI, not
|
||
from this codebase.
|
||
- Needs `BREVO_API_KEY`/`BREVO_LIST_ID` set in the deployment environment
|
||
— confirmed live end-to-end 2026-07-23. As of the double-opt-in switch,
|
||
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
|
||
signup silently no-ops without it) and optionally
|
||
`BREVO_DOI_REDIRECT_URL`.
|
||
- **The already-subscribed error now clears on the next interaction**
|
||
(2026-07-25), matching standard form-validation behavior instead of
|
||
sitting there until the next submit — `useNewsletterSignup.ts`'s
|
||
`handleEmailChange`/new `handleConsentChange` both call a shared
|
||
`clearSubmitError()` that resets `status`/`error` back to idle. This
|
||
replaced the hook's previously-exposed raw `setConsent` with
|
||
`handleConsentChange` in its return value — all 4 consuming forms
|
||
updated to match.
|
||
- **`NewsletterModal.tsx`'s photo no longer resizes when the error
|
||
appears** (2026-07-25) — the modal's left photo stretches
|
||
(`items-stretch`, `md:aspect-auto`) to match the right column's height,
|
||
so the emailError/already-subscribed messages growing that column used
|
||
to visibly grow the photo along with it. Both messages are now always
|
||
rendered with a reserved `min-h-[1.05rem]` instead of conditionally
|
||
mounted, so toggling them no longer changes the column's height at all.
|
||
|
||
## Orders & customer accounts
|
||
|
||
An account is required to buy — there is no guest checkout. Registration
|
||
happens inline in `/checkout`'s "1. Rechnungsadresse" card (a password
|
||
field appears there when nobody's logged in). There's no persistent
|
||
"already a customer? log in" prompt — that was gendered ("Schon Kundin?")
|
||
and shown to every logged-out visitor regardless of relevance. Instead,
|
||
the email field's `onBlur` calls `/api/account/check-email`
|
||
(`checkEmailExists()` in `customerAuth.ts`, service-secret authenticated —
|
||
Customers isn't public-read) and only *then* swaps Card 1's own
|
||
"Passwort (für dein neues Konto)" field out for an inline login prompt
|
||
(password field + "Einloggen" button), gender-neutral copy, rendered
|
||
right under the same email field the shopper just typed into rather than
|
||
asking for it a second time in a separate field. `handleSubmit`'s own
|
||
`emailExists` handling (see "Checkout registration collisions" below) is
|
||
the fallback for the case this check was skipped or raced.
|
||
|
||
**Fixed 2026-07-24** — this login prompt used to render in a completely
|
||
separate block above the whole form (before `<form>` even opens), which on
|
||
a shopper who'd already scrolled down to reach Card 1's email field meant
|
||
the prompt popped in off-screen, above their current scroll position, with
|
||
no auto-scroll wired up for this common blur-triggered path (only the
|
||
submit-time fallback had one). Moved inline into Card 1 itself instead —
|
||
no scrolling needed in the common case since it now appears exactly where
|
||
the shopper is already looking. The submit-time fallback (see "Checkout
|
||
registration collisions" below) still scrolls it into view, now via a
|
||
`useEffect` watching `showLogin` rather than a synchronous call at the
|
||
`setShowLogin(true)` site — the prompt is conditionally rendered, so its
|
||
ref isn't attached to anything yet at that exact synchronous point.
|
||
|
||
- **`app/lib/customerAuth.ts`** (server-only) is the single place that
|
||
talks to Payload's `customers` collection — a second, fully separate
|
||
`auth: true` collection from any admin login, existing purely for this
|
||
storefront's own accounts. Payload issues a JWT on register/login; this
|
||
app never relies on Payload's own auth cookie (different origin —
|
||
`einfach-produktiv.mk360.de` vs `payload.mk360.de`) and instead mints its
|
||
**own** httpOnly `ep_customer_token` cookie holding that JWT, forwarded
|
||
as an `Authorization: JWT <token>` header on every subsequent Payload
|
||
call.
|
||
- **`app/api/account/*`** — thin route handlers around `customerAuth.ts`:
|
||
`register`, `login`, `logout`, `me`, `orders` (list), `profile`
|
||
(GET/PATCH incl. the one saved default address), `password` (verifies
|
||
the current password via a real login attempt before changing it,
|
||
doesn't just trust the caller), `cart` (GET/POST, see below),
|
||
`verify-email`, `resend-verification`, `delete`, `export` (see "Email
|
||
verification" and "GDPR self-service" below), `forgot-password`,
|
||
`reset-password` (see "Password reset" below),
|
||
`orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
|
||
cancellation & returns" below), and `orders/[orderNumber]/invoice`
|
||
(GET — invoice PDF download, see "Invoice PDFs" below).
|
||
- **`/konto/bestellungen`** lists a customer's own orders (status shown as
|
||
a colored `OrderStatusBadge.tsx`, plus an "Abmelden" link —
|
||
`LogoutButton.tsx`); **`/konto/bestellungen/[orderNumber]`** shows one
|
||
order's full detail (items, address, totals, `status`, plus
|
||
companyName/VAT-ID and a VAT-exemption note when the order has them —
|
||
added 2026-07-23, `CustomerOrderDetail` already carried these fields but
|
||
the page never rendered them). `status`
|
||
(`received` → `processing` → `shipped` → `delivered`, plus
|
||
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
|
||
Payload admin for the shipping states — no shipping-carrier API
|
||
integration.
|
||
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
|
||
here** — that's a `pending_payment` order whose payment failed or timed
|
||
out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
|
||
a real Storno (which is always of an already-`received`, already-
|
||
invoiced order, so it always has an `invoiceNumber`). From the
|
||
customer's point of view a payment that never went through was never
|
||
really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
|
||
(`app/lib/customerAuth.ts`) filter these out by default — the row still
|
||
exists in Payload for admin/audit purposes (shown there as "Zahlung
|
||
fehlgeschlagen", see the backend's own README), just not surfaced to
|
||
the customer. `getCustomerOrderDetail` takes this as an **optional**
|
||
4th param, defaulting `false` — `/api/checkout/status/route.ts`'s
|
||
post-payment polling deliberately calls it unfiltered, since that flow
|
||
needs to keep seeing exactly this order (to show "Zahlung
|
||
fehlgeschlagen, bitte erneut versuchen") for the one case this filter
|
||
would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
|
||
opts out (`false`) — a legal completeness export can't silently drop
|
||
rows.
|
||
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
|
||
header itself — not duplicated inside the mobile fullscreen menu, see
|
||
"Mobile navigation" below) is the only *always*-reachable way into
|
||
`/konto/*` — added after discovering there previously wasn't one:
|
||
`/checkout`'s own login toggle only renders once the cart already has
|
||
items (its empty-cart state is an early return with no such toggle), and
|
||
`/bestellbestaetigung`'s "Meine Bestellungen ansehen" link only exists
|
||
after a completed order. A returning customer with an empty cart and no
|
||
recent order had no way to reach the login page at all before this.
|
||
Fetches auth state client-side via `/api/account/me` (not through the
|
||
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
|
||
static/ISR-cacheable — doesn't get forced into per-request dynamic
|
||
rendering just to know one icon's href; briefly shows the logged-out
|
||
state on first paint until that fetch resolves. Re-fetches on an
|
||
`ep-auth-changed` `window` event (`app/lib/auth.ts`'s `dispatchAuthChanged()`,
|
||
called by every login/logout/checkout-registration call site) — the icon
|
||
otherwise never noticed a login/logout until a hard reload, since
|
||
`router.refresh()` only re-runs Server Components, not an
|
||
already-mounted Client Component's effects, and this Navbar lives in the
|
||
root layout and never unmounts across navigations. The icon itself also
|
||
gets a small brand-colored underline while logged in — same visual
|
||
language as the desktop nav links' active-state indicator — since the
|
||
icon alone doesn't otherwise signal session state at a glance.
|
||
|
||
### Mobile navigation
|
||
|
||
Below `lg` (1024px), the hamburger opens a **fullscreen** panel
|
||
(`Navbar.tsx`, `motion.div` from the `motion/react` package already used
|
||
elsewhere in this app for `NewsletterModal`/`VersandModal`) — not an
|
||
in-flow accordion pushed under the header like before. A circular
|
||
`clip-path` reveal (`circle(0vmax at 100% 0%)` → `circle(150vmax at 100%
|
||
0%)`, `vmax` rather than `%` so full coverage holds regardless of aspect
|
||
ratio) expands from the hamburger's own top-right corner, sweeping toward
|
||
the opposite corner last. Nav links fade/rise in with a per-item stagger
|
||
once the reveal has visibly opened up. The panel is a **sibling** of
|
||
`<header>`, not a child — `mobileOpen` gives the header its own
|
||
`backdrop-blur`, which would make it a new CSS containing block for any
|
||
`position: fixed` descendant and break the panel's fixed-to-viewport
|
||
positioning (same class of bug documented on `NewsletterModal`). No
|
||
login/account CTA inside the panel — that's reachable via the account icon
|
||
in the header itself, which stays visible above the panel throughout.
|
||
- **Checkout registration collisions**: if the email typed into Card 1
|
||
during inline registration already belongs to an existing account,
|
||
Payload's create call fails — `registerCustomer()` in `customerAuth.ts`
|
||
detects this specifically (`emailExists: true` on the returned
|
||
`AuthResult`, inferred from the field flagged in Payload's validation
|
||
error, since Payload's own message text doesn't distinguish "duplicate"
|
||
from other email-field failures) rather than just surfacing a generic
|
||
error. `CheckoutContent.tsx`'s `handleSubmit` reacts by switching
|
||
`showLogin` on (same inline prompt described above — reuses Card 1's
|
||
own email field, nothing to pre-fill) and scrolling it into view via a
|
||
`useEffect`, instead of leaving the customer stuck with an error and no
|
||
obvious next step. `handleLogin()` itself posts the live `email` field
|
||
value, not a separate `loginEmail` state — there's only ever one email
|
||
input on this form now.
|
||
- **`/konto/profil`** edits name + the one saved default address (deliberately
|
||
a single address, not a full address book — see the assistant's memory
|
||
note on optionally expanding this later), changes the password, shows
|
||
the email-verification banner, and has the GDPR export/delete section.
|
||
Its "Land" `<select>` used to hardcode Deutschland/Österreich/Schweiz
|
||
independently of `/checkout`'s own country list — **fixed 2026-07-24**:
|
||
`ProfileForm.tsx` now takes a `shippingCountries` prop (`page.tsx` fetches
|
||
`getShippingCountries()`, same Payload-configurable list `/checkout`
|
||
already reads), so a country added/removed in the admin reaches both
|
||
places instead of just one.
|
||
- **Cart sync**: `app/components/CartSync.tsx` (mounted once in
|
||
`app/layout.tsx`) watches the local cart via `useCart()` and
|
||
debounce-POSTs it to `/api/account/cart` on every change; the route
|
||
401s (silently, by design) when nobody's logged in. On login
|
||
(`LoginForm.tsx`, and `CheckoutContent.tsx`'s inline toggle),
|
||
`mergeServerCartIntoLocal()` (`app/lib/cart.ts`) folds whatever was
|
||
saved server-side into the local cart by quantity — CartSync's own
|
||
effect then pushes the merged result back up on its own, so there's no
|
||
separate explicit "save after merge" call.
|
||
- Still out of scope: a full address book (single default address only —
|
||
see the assistant's memory note), single-currency.
|
||
|
||
### Rate limiting
|
||
|
||
`app/lib/rateLimit.ts` — an in-memory, per-IP sliding-window limiter
|
||
(`checkRateLimit(key, {limit, windowMs})`), deliberately no Redis: this
|
||
app runs as a single Coolify container, so a plain `Map` is enough and
|
||
needs no new infra. Resets on redeploy/restart — acceptable at this
|
||
shop's traffic level; revisit with a shared store if this ever scales to
|
||
multiple instances. Applied (keyed by `X-Forwarded-For`, which Caddy
|
||
already sets) to `/api/account/register`, `/api/account/login`,
|
||
`/api/account/password`, and `/api/account/resend-verification`.
|
||
|
||
This complements, not replaces, Payload's own **per-account** login
|
||
lockout (`customers.auth`, `maxLoginAttempts: 5` / `lockTime: 10min`,
|
||
Payload defaults — see the Payload README's "Login rate limiting"
|
||
section) — that stops brute-forcing one known email, this stops an IP
|
||
spraying attempts across many, or hammering registration.
|
||
|
||
### Session refresh
|
||
|
||
`proxy.ts` (project root — Next.js 16 renamed `middleware.ts` to
|
||
`proxy.ts`; see `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`
|
||
if this ever looks wrong against older docs/training data). Runs on
|
||
`/checkout`, `/konto/*`, `/api/account/*`. Decodes (not verifies — Payload
|
||
verifies for real on every actual API call) the `ep_customer_token`
|
||
cookie's JWT `exp` claim; if less than 15 minutes remain, silently calls
|
||
Payload's built-in `POST /api/customers/refresh-token` and swaps in the
|
||
refreshed token. Net effect: an actively-browsing customer never gets
|
||
logged out mid-session, but someone who walks away is logged out within
|
||
~2h of their last request (Payload's `tokenExpiration` default, unchanged
|
||
on the Payload side).
|
||
|
||
### Email verification
|
||
|
||
Non-blocking by design — see the Payload README's `customers.emailVerified`
|
||
section for why this is a custom flag rather than Payload's built-in
|
||
`auth.verify: true` (short version: that would hard-block login for a
|
||
brand-new customer trying to finish the purchase they just registered
|
||
mid-checkout for). The initial email is sent by Payload itself (an
|
||
`afterChange` hook on `customers`, fires on create). `/konto/profil`'s
|
||
`VerificationBanner.tsx` shows a non-blocking "bitte bestätigen" hint with
|
||
a resend link when `!profile.emailVerified`; resending
|
||
(`/api/account/resend-verification`) is sent directly from this app
|
||
instead (`app/lib/alertAdmin.ts`'s `sendVerificationEmail()` — same
|
||
Hostinger SMTP, no Payload hook to piggyback on for a plain field update).
|
||
|
||
### Password reset
|
||
|
||
Unlike email verification, this needed no custom flag — `forgotPassword`
|
||
doesn't block login, so it's Payload's built-in flow as-is (see the
|
||
Payload README's `customers.auth.forgotPassword` section), just with the
|
||
email content/destination swapped so the link points here instead of the
|
||
Payload admin. `/konto/passwort-vergessen` (`ForgotPasswordForm.tsx`) →
|
||
`POST /api/account/forgot-password` → always responds `{ok:true}`
|
||
regardless of whether the email exists (same anti-enumeration reasoning as
|
||
Payload's own operation — the route must not leak a different response
|
||
shape for "no such account", see its own comment). `/konto/passwort-zuruecksetzen?token=...`
|
||
(`ResetPasswordForm.tsx`, token read server-side from `searchParams` —
|
||
avoids needing a `<Suspense>` boundary, unlike the `?code=` cart case
|
||
above which genuinely needs client-side `useSearchParams()`) →
|
||
`POST /api/account/reset-password` → Payload logs the customer in on a
|
||
successful reset (returns the same `{token, user}` shape as login), so the
|
||
session cookie is set immediately, no separate login step. `LoginForm.tsx`
|
||
links to `/konto/passwort-vergessen`.
|
||
|
||
### Email templates & Live Preview
|
||
|
||
All 9 transactional emails (order confirmation, password reset, and the 7
|
||
status-change types below — the original 4 plus `order-tracking-added`/
|
||
`order-tracking-corrected`/`order-delivered`, added 2026-07-25) read their
|
||
subject/heading/body/footer wording from Payload's `email-templates`
|
||
collection — editable in the admin without a deploy, with a Live Preview
|
||
button using the exact same mechanism as Posts/LegalPages/Testimonials
|
||
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
|
||
dependency here for `LivePostContent.tsx`).
|
||
|
||
**Live Preview only reliably updates when popped out into its own
|
||
browser tab/window, not in the embedded admin panel.** Root cause: the
|
||
preview goes through `buildPreviewUrl()` on the Payload side, which hits
|
||
`/api/preview` on this frontend's own origin to enable Next.js Draft
|
||
Mode via a cookie — but admin (`payload.mk360.de`) and frontend
|
||
(`einfach-produktiv.mk360.de`) are different origins, so from inside the
|
||
admin's embedded `<iframe>` that's a cross-site/third-party context.
|
||
Modern browsers (Safari by default, Chrome/Firefox increasingly)
|
||
block or partition cookies set inside a cross-site iframe regardless of
|
||
which domain actually issued them, so the Draft Mode cookie doesn't
|
||
reliably persist there. Once popped into its own window it's a top-level
|
||
navigation, not a third-party context, so the cookie sets normally and
|
||
everything works. Affects every Live-Preview-enabled collection in this
|
||
system (Posts/LegalPages/Testimonials too), not just email templates —
|
||
a structural consequence of running admin and frontend on separate
|
||
domains, not something fixable at the collection-config level. A real
|
||
fix would mean serving both under one domain (reverse proxy path) rather
|
||
than two subdomains.
|
||
|
||
- **`app/lib/emailTemplates.ts`** — pure string-building functions
|
||
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
|
||
`renderOrderStatusHtml()`), no server-only or client-only imports. Used
|
||
**both** server-side for the real send (`orderEmail.ts`) **and**
|
||
client-side for the Live Preview page — same function, same inputs, so a
|
||
Live Preview edit and the real sent email are guaranteed to render
|
||
identically for order-confirmation, the only one of the 6 actually sent
|
||
from this repo (password-reset and all 4 status-change types are sent by
|
||
Payload itself, using its own inline templates — see that repo's README
|
||
for why — so their Live Previews approximate rather than pixel-match).
|
||
Inline-styled HTML (`<table>` layout, `style` attributes, no
|
||
Tailwind/`<style>` block) — most email clients strip external/embedded
|
||
CSS.
|
||
- **`/email-preview/[type]/page.tsx`** — entered exclusively from Payload's
|
||
admin iframe (`EmailTemplates.ts`'s `admin.livePreview.url`), never a
|
||
real visitor destination (`noindex`). Always reads with `draft: true` so
|
||
an unsaved admin edit shows immediately. Renders against **sample data**
|
||
(`SAMPLE_ORDER` in `emailTemplates.ts`) — unlike the other three Live
|
||
Preview targets, there's no "current" real order/reset-link to preview
|
||
against generically.
|
||
- The *real* send always reads the **published** template
|
||
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
|
||
Preview edit never affects a live customer email until actually saved.
|
||
- **`active` toggle (2026-07-25)** — each row now has an `active`
|
||
checkbox (backend `EmailTemplates.ts`); off suppresses the send
|
||
entirely, checked here for `order-confirmation`
|
||
(`orderEmail.ts`'s `sendOrderConfirmationEmail()`) before the hardcoded
|
||
default-wording fallback ever applies. See the backend repo's own
|
||
README ("Email templates: active/inactive toggle") for the full
|
||
picture, including the `password-reset` exception (Payload's core
|
||
`forgotPassword` operation has no hook to actually cancel that send).
|
||
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
|
||
defaults for all 9 rows — deliberately on-brand and a little playful
|
||
("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles
|
||
klar." / "Alles erledigt.", not generic transactional-email
|
||
boilerplate), matching this site's voice elsewhere (see e.g. the
|
||
testimonial copy). Two intentional exceptions to that voice: the
|
||
email-verification mail (Payload-side, plain functional copy — see that
|
||
README) and the Stornorechnung/Gutschrift PDFs (formal legal documents,
|
||
no brand voice by design). Editable in the admin afterward regardless.
|
||
`sendOrderConfirmationEmail()` also has a hardcoded fallback for the
|
||
rare case a fresh install's order arrives before that seed has run.
|
||
- **`emailShell()`'s visual design deliberately echoes `/bestellbestaetigung`**
|
||
(the on-screen order confirmation page) rather than reading as a generic
|
||
transactional email: same warm cream background/brand color as
|
||
`globals.css`'s `--color-*` tokens (hardcoded here as literal hex — email
|
||
clients don't resolve `var()` either), a circular brand-tinted icon
|
||
(✓ for order-confirmation, ✉ for password-reset) echoing that page's own
|
||
success-icon treatment, a thin brand-colored divider under the heading,
|
||
and a Georgia/serif heading font as the closest reliably-available
|
||
approximation of the site's Playfair Display (most email clients strip
|
||
`@font-face`/external font requests, so an actual web font isn't an
|
||
option here). Not shared code with the React page — this is plain
|
||
inline-styled HTML built for email-client compatibility (nested
|
||
`<table>`s, no flexbox) — just matched by eye.
|
||
- **Footer carries a full legal Anbieterkennzeichnung, not just a brand
|
||
line.** `emailShell()` takes a `footerLines: string[]` array built by
|
||
`buildLegalFooterLines(seller)` — `sellerName`, `sellerStreet`,
|
||
`sellerZip`/`sellerCity` (+ `sellerCountry` if not Germany), `E-Mail:
|
||
sellerEmail`, and `USt-IdNr.: vatId` when set — the same admin-editable
|
||
`company-settings` fields the invoice PDFs already use, rather than a
|
||
literal `"einfach produktiv · admin@mk360.de"` string. `orderEmail.ts`
|
||
and `alertAdmin.ts` (both the resend-verification mail and the plain-text
|
||
critical-alert mail) all fetch `company-settings` once
|
||
(`getSellerForInvoice()`) and pass the `seller` object straight into the
|
||
render functions, which call `buildLegalFooterLines()` themselves — one
|
||
place composes the footer, not each call site. The Payload-side sends
|
||
(password-reset, the 4 status-change emails, the *initial* verification
|
||
email) get the equivalent treatment via that repo's own
|
||
`src/lib/sellerInfo.ts`'s `buildLegalFooterLines()`/`getSellerFooterLines()`
|
||
— see that repo's README for its own copy of this section. Live Preview
|
||
passes `seller: null`, which falls back to `DEFAULT_LEGAL_FOOTER_LINES` (a
|
||
placeholder Anbieterkennzeichnung) since there's no real order/tenant
|
||
context there to fetch against. Note `company-settings` currently has no
|
||
Handelsregister court/number or Geschäftsführer field — fine for a sole
|
||
proprietorship, but would need adding if the business becomes a
|
||
registered legal form (GmbH etc.), see that collection's own field list
|
||
above.
|
||
- **`From` display name is dynamic (`seller.sellerName`), the address
|
||
itself stays `admin@mk360.de`.** `Reply-To` is set to `seller.sellerEmail`
|
||
so a customer's reply actually reaches the seller regardless of the From
|
||
address. The address isn't also switched to `sellerEmail` because that
|
||
domain isn't confirmed SPF-authorized on the Hostinger account backing
|
||
`admin@mk360.de` yet — doing so without that confirmation risks
|
||
order-confirmation/verification mail landing in spam or bouncing outright.
|
||
Both `orderEmail.ts` and `alertAdmin.ts`'s `sendVerificationEmail` set
|
||
this the same way; `sendCriticalAlert` (internal, admin@mk360.de to
|
||
itself) doesn't need it.
|
||
|
||
### GDPR self-service
|
||
|
||
`/konto/profil`'s "Konto & Daten" section:
|
||
- **Export** (`/api/account/export`, GET) — profile + every order's full
|
||
detail as one downloadable JSON (`Content-Disposition: attachment`).
|
||
Genuinely complete, not a summary — Art. 20 data portability.
|
||
- **Delete** (`/api/account/delete`, POST, password re-verified via a real
|
||
login attempt first) — deletes the `customers` document. Past orders
|
||
are **not** touched: `orders.customer` is `ON DELETE SET NULL` in
|
||
Payload, so an order keeps its own name/address/items snapshot (already
|
||
stored independently for exactly this kind of reason) for tax-retention
|
||
purposes (§147 AO / GDPR Art. 17(3)(b) explicitly permits this) — only
|
||
the account/login itself disappears. The UI says this explicitly before
|
||
deleting, not as a surprise afterward.
|
||
|
||
### Order cancellation & returns
|
||
|
||
`/konto/bestellungen/[orderNumber]` shows one self-service button when
|
||
applicable: "Bestellung stornieren" while `status === 'received'`, or
|
||
"Rücksendung anfragen" while `status` is `'shipped'` or `'delivered'`
|
||
(`customerOrderAction()` in `customerAuth.ts` decides which, if any).
|
||
Posts to `/api/account/orders/[orderNumber]` (PATCH), which re-checks the
|
||
transition is still valid (friendlier error than a bare 403 if it's gone
|
||
stale — two tabs open, order shipped in the meantime) before calling
|
||
`requestOrderStatusChange()`.
|
||
|
||
Requesting a return opens an inline form (`OrderActionButton.tsx`), not
|
||
just a confirm dialog — **partial returns are supported**: a quantity
|
||
input per order line (0 up to that line's ordered quantity) plus a
|
||
required reason textarea. At least one line must have a nonzero quantity
|
||
to submit. Cancel stays a plain `window.confirm()` — it's a lower-stakes,
|
||
whole-order-only action (before shipping, often just a change of mind),
|
||
no quantity picker or reason needed there.
|
||
|
||
The route (`/api/account/orders/[orderNumber]` PATCH) reconstructs the
|
||
order's *full* `items` array before sending it to Payload — only the
|
||
requested lines' `returnQuantity` differs from what's already stored,
|
||
every other field (product/price/tax rate/etc.) is passed through
|
||
unchanged. This isn't optional: Payload's array field expects every
|
||
required sub-field present on each row, and the field-lock hook (see
|
||
below) specifically checks that only `returnQuantity` changed — a sparse
|
||
`{ returnQuantity: 2 }`-only patch would fail both.
|
||
|
||
**The real security boundary is in Payload**, not here: `orders.access.update`
|
||
already scoped a customer's JWT to their own order, but with no
|
||
field-level restriction — before this stage, a logged-in customer could in
|
||
principle PATCH *any* field of their own order (`total`, `items`,
|
||
anything), just because nothing in the frontend had ever exercised that
|
||
path yet. `Orders.ts`'s `beforeChange` hook rejects a customer-authenticated
|
||
update unless the change is limited to `status` (plus `returnReason` and
|
||
each item's `returnQuantity`, bounded 0..quantity, alongside a
|
||
`return_requested` transition), via an allowed transition. See the
|
||
Payload README's own writeup for the full detail — including
|
||
`orderUpdateValidation.ts`, where this logic now lives as a unit-tested
|
||
pure function.
|
||
|
||
No hard 14-day return-window check in code (no separately tracked delivery
|
||
date exists yet) — relies on the existing `/widerruf` legal text plus
|
||
manual admin review. No automatic refund (no payment provider exists yet)
|
||
— a return/cancellation request is just captured structurally instead of
|
||
arriving by email/phone; the admin still processes it by hand in the
|
||
Payload admin.
|
||
|
||
Reaching `status: 'cancelled'` or `status: 'returned'` also auto-generates
|
||
a **Stornorechnung**/**Gutschrift** correction-invoice PDF, attached to
|
||
that status's customer email (Payload-side, see the Payload README's "How
|
||
a Stornorechnung/Gutschrift relates to the original invoice" section —
|
||
this is not frontend code). Stornorechnung is always a full reversal
|
||
(cancellation is always pre-shipping, whole order, shipping included).
|
||
Gutschrift reflects only the returned quantities — full or partial —
|
||
**excludes shipping** (already delivered by the time a return is
|
||
possible) and **never reprorates the original discount** (confirmed
|
||
policy, not a default: the discount stays with whatever's kept). Either
|
||
way, this is a document only, not a money movement: an actual refund
|
||
still has to happen manually, since no payment provider exists yet to
|
||
capture or reverse a real charge.
|
||
|
||
### Status-change emails
|
||
|
||
Four `orders.status` transitions trigger a customer email — `shipped`,
|
||
`cancelled`, `return_requested`, `returned` (deliberately not `delivered`,
|
||
redundant with the carrier's own notification; not `processing`/`received`,
|
||
not customer-actionable). **Sent entirely from Payload**, not this repo —
|
||
`orders.ts`'s `afterChange` hook there compares `doc.status` to
|
||
`previousDoc.status` and fires regardless of who made the change (an admin
|
||
setting `shipped`/`returned` in the Payload admin, or the customer's own
|
||
self-service cancel/return-request above land on the exact same hook). See
|
||
the Payload README's `orders.ts` section for the actual send logic,
|
||
including the Stornorechnung/Gutschrift attachment on cancelled/returned.
|
||
|
||
This repo's only involvement is the **Live Preview approximation** — same
|
||
established gap as password-reset (see below): `renderOrderStatusHtml()`
|
||
in `app/lib/emailTemplates.ts` and the corresponding entries in
|
||
`/email-preview/[type]`'s `VALID_TYPES` exist purely so an admin editing
|
||
`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`
|
||
in the `email-templates` collection sees a reasonable preview — the actual
|
||
sent HTML is Payload's own `src/lib/emailShell.ts` render, not this file's.
|
||
|
||
### Monitoring & alerting
|
||
|
||
Base uptime (is the site/Payload reachable at all) is already covered by
|
||
existing Uptime Kuma HTTP monitors with email alerting (`monitor.mk360.de`
|
||
— see `~/dev/README.md`'s Kuma section) and isn't part of this app. What's
|
||
new here is the one failure mode Kuma structurally can't see: the site is
|
||
up, a customer completes checkout, and the order still doesn't get
|
||
persisted (`createOrder()` returns `null` in `/api/checkout/route.ts`).
|
||
That path calls `app/lib/alertAdmin.ts`'s `sendCriticalAlert()` — its own,
|
||
independent SMTP connection (same Hostinger account, but **not** routed
|
||
through Payload, since Payload being the actual problem is one of the
|
||
scenarios this needs to still report on). Fire-and-forget, its own
|
||
try/catch, never blocks or fails the actual error response the customer
|
||
sees.
|
||
|
||
`/api/health` (GET) — checks Payload's public API is actually reachable
|
||
(3s timeout), not just that this page rendered; added as a Kuma HTTP
|
||
monitor in the existing "Content & API" group (`~/dev/README.md`'s
|
||
documented `sqlite3`-insert method, Kuma 1.x has no REST API for this).
|
||
|
||
`/shop` itself also has its own Kuma HTTP monitor ("einfach-produktiv Shop
|
||
(Produkte, Varianten, Lagerbestand)", same "Content & API" group) — added
|
||
once the shop grid started doing real work at render time (`fullyOutOfStock`
|
||
across a product's variants, `effectivePrice()`), not just listing static
|
||
content; `/api/health` alone only proves Payload is reachable, not that this
|
||
specific page still renders. The Payload jobs queue's own failure monitor
|
||
(`/api/health/jobs`, `hasError: true` in the last 24h) already covers all
|
||
five scheduled jobs generically by task-agnostic query — the four added this
|
||
session (low-stock digest, stale-unverified-accounts report, weekly revenue
|
||
report, expired-discount-code cleanup) needed no monitor changes of their
|
||
own; see the Payload README's "Jobs Queue" section.
|
||
|
||
## Mobile responsive pass (2026-07-24)
|
||
|
||
A full-site pass fixing concrete Mobile-width bugs across the Home Hero,
|
||
Navbar, Newsletter, cart, `/challenge`, `/todo-cards`, blog detail, and the
|
||
legal pages — found by testing on a real phone rather than just resizing a
|
||
desktop browser to 768px. The detailed technical lessons (aspect-ratio
|
||
distortion from mixing fluid/fixed sizing, `preserveAspectRatio="none"` SVGs,
|
||
`whileInView` failing to trigger on short/narrow viewports, etc.) are written
|
||
up as dated Gotchas in the `figma-to-nextjs` skill
|
||
(`~/.claude/skills/figma-to-nextjs/SKILL.md`) rather than duplicated here —
|
||
that's now the reference for "why" on any of this. Two changes are worth
|
||
calling out specifically since they add new shared components:
|
||
|
||
- **`app/components/StepArrow.tsx`** — a small inline-SVG connector arrow,
|
||
replacing `/icon-arrow-connector.svg` across all three "how it works" step
|
||
sections (`/challenge`, `/todo-cards`, `/newsletter`). The old asset's
|
||
color lived in a CSS custom property scoped to the SVG file itself, so it
|
||
could never actually become the brand orange used everywhere else once
|
||
loaded via `next/image` — converting it to a real component was the only
|
||
fix, and having one shared component means a future color/shape/size
|
||
tweak is one edit instead of three.
|
||
- **`app/components/SectionTOC.tsx`'s `MobileSectionTOC`** — the
|
||
Impressum/Datenschutz/AGB/Widerruf/Versand table-of-contents sidebar was
|
||
`hidden` entirely below `lg:` (a 360px sidebar genuinely doesn't fit next
|
||
to a readable content column below that), which meant Mobile/Tablet had no
|
||
on-page navigation aid at all on these often-long pages — exactly where
|
||
scanning a long legal document by scrolling is hardest. `MobileSectionTOC`
|
||
is a `<details>/<summary>` accordion (no extra JS state needed) sharing the
|
||
same scroll-spy "active section" logic (extracted into a `useActiveSection`
|
||
hook) as the desktop sidebar nav, rendered as its own element right after
|
||
each page's heading — not nested inside the sidebar's `hidden lg:...`
|
||
wrapper, which would hide it too regardless of its own `lg:hidden` class.
|
||
|
||
## Custom post content blocks, and SEO settings (2026-07-24)
|
||
|
||
`app/components/RichText.tsx` switched from a small hand-rolled Lexical
|
||
JSON→JSX walker to Payload's official `@payloadcms/richtext-lexical/react`
|
||
renderer + custom `JSXConverters` — needed once the Payload repo's
|
||
`Posts.content` gained custom Lexical Blocks (`Bild`/`Bildergalerie`/`Video`/
|
||
`Zitat`, see the Payload README's own section on this), which the old
|
||
hand-rolled `switch` had no case for at all. Kept the exact same exported
|
||
call signature (`RichText({ content, quoteLabel })`), so `LiveRichText.tsx`
|
||
and `app/blog/[slug]/components/LivePostContent.tsx` needed zero changes.
|
||
`extractHeadings()`/`headingId()` (used by `SectionTOC` on legal pages) stay
|
||
an independent, minimal walk over the raw JSON, unrelated to the new
|
||
renderer — legal pages don't use Blocks.
|
||
|
||
Block converters follow this project's established "CMS-sourced image"
|
||
convention (`relative` + `aspect-[…]` + `next/image fill` + `object-cover`,
|
||
see the blog thumbnail treatment) — a gallery block renders as a
|
||
`grid-cols-2` grid, a video block resolves a YouTube/Vimeo URL into an
|
||
`<iframe>` embed. The converters object is built fresh per `RichText()` call
|
||
(a factory closing over that call's own `quoteLabel`), not a module-level
|
||
constant — Server Components can render multiple posts concurrently in the
|
||
same process, so a shared mutable variable would be a real race condition.
|
||
|
||
**SEO settings** — new `getSeoSettings()` in `app/lib/payload.ts` (same
|
||
ISR-cached, public-safe-subset-of-CompanySettings pattern as
|
||
`getKleinunternehmer()`), with a hardcoded fallback equal to what used to be
|
||
directly in `app/layout.tsx`. `app/layout.tsx`'s `metadata` export became
|
||
`generateMetadata()` reading it. `PostDetail`/`mapPayloadPost`/
|
||
`getPostBySlug()` gained `seoTitle`/`seoDescription`/`seoImage`, each
|
||
falling back to the post's own `title`/`excerpt`/`thumbnail` when empty;
|
||
`app/blog/[slug]/page.tsx`'s `generateMetadata()` uses them (now also
|
||
setting a `twitter` block, previously missing). A few smaller gaps found
|
||
while auditing every page's metadata were fixed in the same pass: 3
|
||
`konto/*` pages were missing a `description`, `/konto/bestellungen/
|
||
[orderNumber]` had a static "Bestelldetails" title despite being a dynamic
|
||
route (now uses the real order number), and `/shop`/`/blog` were missing an
|
||
Open Graph image.
|
||
|
||
## Tablet responsive fixes (2026-07-24)
|
||
|
||
A follow-up to the Mobile pass above — testing at real Tablet widths
|
||
(768-1023px) surfaced a few spots where the Mobile-focused fixes had left
|
||
Tablet worse off than before, or where a component's own structural
|
||
breakpoint no longer needed to be as conservative as originally set:
|
||
|
||
- **`Hero.tsx` reverted from `lg:` back to `md:` as its structural
|
||
breakpoint.** It was moved to `lg:` earlier (see the figma-to-nextjs
|
||
skill's Gotcha #5) because at `md:col-span-5` the text column was only
|
||
~283px at 768px and the then-full-size CTA/subtitle text made the whole
|
||
row wrap to 3 cramped lines. The smaller fixed CTA/subtitle/icon sizes
|
||
added during the Mobile pass (for true phone widths) are well under half
|
||
that column's width, so the original wrapping problem doesn't recur —
|
||
moving back to `md:` means Tablet gets the real 5/7 grid (image beside
|
||
text) again instead of a phone-style stacked layout. Every `lg:`-gated
|
||
size override in that file moved to `md:` to match.
|
||
- **`Tools.tsx`'s card icon** — full 56px size pushed from `md:` to `lg:`;
|
||
at Tablet the title/description text is still close to its own fluid
|
||
floor, and the full-size icon read as too big next to it.
|
||
- **`About.tsx`'s text/photo columns** — the Desktop overlap layout
|
||
(`flex-[1.4_0_0]` on the photo, `-ml-48`, gradient) kicked in from `md:`
|
||
already, giving the text column (fixed against the smaller 1:1.4 share)
|
||
too little room for its fixed-width statement + quote/bio row at Tablet.
|
||
The ratio is swapped at Tablet (text gets the bigger 1.4 share, photo the
|
||
smaller one, no overlap) and reverts to the original ratio + overlap only
|
||
from `lg:` up, where it was designed for.
|
||
- **`Newsletter.tsx`'s input+submit row** and **`Footer.tsx`'s logo/handle/
|
||
legal-links row** — both went side-by-side at `md:`, but the columns
|
||
around them (the newsletter card's fixed-width copy column; the footer's
|
||
3 groups sharing one `justify-between` row) didn't leave enough width at
|
||
768px. Both pushed from `md:flex-row` to `lg:flex-row` — stacked through
|
||
the whole Tablet range, side by side again once there's real room.
|
||
|
||
## Structured data (schema.org, 2026-07-25)
|
||
|
||
JSON-LD (`<script type="application/ld+json">`) on the pages Google
|
||
actually gives rich results for — built via pure functions in
|
||
`app/lib/structuredData.ts`, no new Payload fields needed, everything
|
||
derived from data that already exists.
|
||
|
||
- **`Organization`** — rendered once, site-wide, in `app/layout.tsx` (via
|
||
`getCompanySettings()`, the same established pattern `/impressum`
|
||
already uses for a public page needing seller data server-side). Only
|
||
non-sensitive fields make it into the schema (name, address, email,
|
||
`vatID`) — `iban`/`bic` never do, even though `getCompanySettings()`
|
||
itself returns them. Has a stable `@id`
|
||
(`https://einfach-produktiv.mk360.de/#organization`) that `Product`/
|
||
`BlogPosting` schemas elsewhere link back to via `{ "@id": ... }`
|
||
instead of repeating the full object on every page (schema.org's own
|
||
recommended pattern for a single canonical entity). Falls back to a
|
||
minimal `{name, url}`-only Organization if `getCompanySettings()`
|
||
can't reach the backend, rather than emitting nothing.
|
||
- **`Product`** — only on `/todo-cards`, the one page with its own
|
||
dedicated URL for a single, purchasable product (`getProductBySlug`).
|
||
Deliberately not added to `/shop`'s grid — most products there have no
|
||
individual detail page to point a `Product`'s `url` at, and Google's
|
||
own guidance is that Product markup belongs on the page where that
|
||
product can actually be viewed/bought, not a generic listing.
|
||
`aggregateRating` is omitted — no reviews/ratings system exists yet
|
||
(see the SOTA-gaps discussion this same session); add it once real
|
||
reviews exist, don't fake it before then.
|
||
- **`BlogPosting`** — on every `/blog/[slug]` page (`buildArticleSchema`).
|
||
`author` is hardcoded `{ "@type": "Person", name: "Björn" }`, matching
|
||
the page's own hardcoded author-bio block — this is a single-author
|
||
blog with no `author` field on `Posts.ts` to read from instead.
|
||
|
||
Verified locally by curling each page and grepping the rendered
|
||
`application/ld+json` script for the expected `@type` — not run through
|
||
Google's Rich Results Test (no live Stripe-style external validation
|
||
step for this), so worth a manual check there once deployed.
|
||
|
||
## Structural breakpoint moved to 640px (2026-07-29)
|
||
|
||
The Tablet layout (768-1023px) was still not landing right after the
|
||
2026-07-24 fixes above: real tablets (768px+) hit the structural `md:`
|
||
grid switch at the exact viewport width where the fluid `clamp()` tokens
|
||
(`app/lib/fluid.ts`) were already at their smallest (floor) value, leaving
|
||
no room to shrink — cramped images/text in 2-column sections. Fixed by
|
||
moving both the fluid floor and the structural switch down together, from
|
||
768px to Tailwind's `sm:` (640px):
|
||
|
||
- **`app/lib/fluid.ts`**: `MIN_VW` 768 → 640. All `clamp()` values in
|
||
`app/globals.css`'s `@theme`/`:root` blocks are hand-authored (no build
|
||
step calls `fluid()`), so they were individually regenerated for the new
|
||
640→1440 range, not just re-anchored by editing the constant.
|
||
- **Hero/About/Newsletter/Footer/Tools** — the `md:`+`lg:` two-tier
|
||
patchwork from the 2026-07-24 fixes above collapsed back onto a single
|
||
`sm:` structural line (grid/flex switches now happen at 640px, aligned
|
||
with the new fluid floor). Content-sizing exceptions that still don't
|
||
fit at 640px (Hero/Tools' fixed-size CTA/text/icon swaps, Newsletter's
|
||
input+button row, About's inner quote/bio row) stay gated behind `lg:`,
|
||
documented in each file as a deliberate exception, not a leftover.
|
||
- Every other component with a plain `md:` structural switch (TrustRow,
|
||
Blog, TestimonialsGrid, ProductSpotlight, ProductGrid, RelatedProducts,
|
||
NewsletterModal, `/blog`, `/not-found`, both `HowItWorks` components)
|
||
renamed mechanically to `sm:`.
|
||
- Components with a genuine fixed-width constraint (Cart/Checkout's
|
||
two-column split, `SectionTOC.tsx` + the 5 legal pages, the newsletter/
|
||
todo-cards hero/benefits sections, `/challenge`) stay on `lg:` — the
|
||
math still doesn't fit at 640px either, only their code comments'
|
||
"768px floor" language was updated.
|
||
- **`Navbar.tsx`** is a deliberate, documented exception to the whole
|
||
migration — its `md:`/`lg:` 3-tier scheme (hamburger-only, hamburger+
|
||
inline-CTAs, full-inline) is about horizontal nav-link overflow, a
|
||
different failure mode than the vertical grid/flex reflows everywhere
|
||
else, with no fluid token that would make a full inline nav fit at
|
||
640px.
|
||
|
||
No browser/screenshot tool is available in this environment, so this
|
||
migration is verified by typecheck/build/lint/`test:unit` only — actual
|
||
visual confirmation at 640/768/1024/1440px is still owed by the user.
|
||
**Update, same day:** a real headless-browser check turned out to be
|
||
possible after all — `npx playwright install chromium` in a scratch
|
||
directory, then a plain Node script driving `playwright`'s `chromium.launch()`
|
||
against the *live* site at a specific viewport, reading
|
||
`document.documentElement.scrollWidth` vs. `window.innerWidth` (a real
|
||
horizontal overflow, not just "looks cramped") and `getBoundingClientRect()`
|
||
on specific elements. This is a real, repeatable verification tool for
|
||
future Tablet-layout work in this environment — see the two regressions
|
||
below, both caught this way, not by guessing from a screenshot.
|
||
|
||
Same session: **`Hero.tsx`'s heading** shortened from two sentences
|
||
("Verliere dich nicht im Mehr. Finde heraus, was wichtig ist.") to one
|
||
("Finde heraus, was wichtig ist") with no trailing period, since the
|
||
brand's orange dot (`PopIn`) already renders one, animated, right after
|
||
it. The homepage's **"3x3-System" werkzeuge-card** description was also
|
||
lengthened to match the other two cards' length (see the Payload repo's
|
||
own README for the `fix-3x3-copy-length.ts` one-off that changed the
|
||
live content).
|
||
|
||
**Correction, 2026-07-29 (later the same day): `Footer.tsx`'s legal-links
|
||
row and `TrustRow.tsx` were wrongly reclassified as plain `sm:` renames
|
||
above.** Both had originally used `lg:flex-row` for a genuine
|
||
fixed-content-width reason (logo + handle + 5 `whitespace-nowrap` legal
|
||
links; multiple `whitespace-nowrap` title+description trust badges) — not
|
||
the "grid arrives before the fluid floor" bug the `sm:` migration
|
||
targets. Renaming them to `sm:` during the mechanical mass-rename pass
|
||
caused a **real horizontal page overflow** in the 640-1023px band on
|
||
every page that renders them (Footer: every page; TrustRow: shop, cart,
|
||
checkout, order-confirmation, `/agb`, `/widerruf`, `/not-found`) —
|
||
confirmed via the Playwright method above: `scrollWidth` exceeded the
|
||
viewport by ~80-100px at 666-768px viewports. Both reverted to `lg:`,
|
||
matching their original (correct) behavior. **Lesson: "this looks
|
||
like the same md:→sm: pattern as everything else" isn't sufficient
|
||
justification on its own — check whether a component was *already* on
|
||
`lg:` for a real fixed-width-content reason (not just inherited from an
|
||
earlier, unrelated Tablet fix) before reclassifying it as a mechanical
|
||
rename.** See the `figma-to-nextjs` skill's Gotcha 21 for the fuller
|
||
writeup.
|
||
|
||
**Follow-up, same day: `TrustRow.tsx`'s alignment went through three more
|
||
rounds after the `lg:` revert above, all centered on the same
|
||
underlying flexbox lesson (skill Gotcha 22).** First, centering its
|
||
badges when stacked (per feedback) was implemented as plain `items-center`
|
||
directly on the flex-col container — but `align-items:center` centers
|
||
*each item independently* within the container's own width, so the 3
|
||
badges (different title/description lengths) ended up with 3 different
|
||
left edges instead of lining up with each other (confirmed via a real
|
||
screenshot: icons at different x-positions). Fixed by wrapping all
|
||
badges in one inner shrink-to-fit group with `items-start` internally
|
||
(so they share one left edge), then centering/left-aligning that single
|
||
group as a unit. **Then tried replacing that whole approach** with `flex
|
||
flex-wrap justify-center` directly on the badges (no inner wrapper, no
|
||
breakpoint at all): flexbox does correctly center each *wrapped line* as
|
||
a group (no misalignment bug), and it removes the overflow risk without
|
||
a `lg:`-only breakpoint, so badges flowed as many-per-row as fit (1 per
|
||
row on a narrow phone, 2-with-the-3rd-below once there's room, all 3 in
|
||
a row at Desktop). **Reverted the same day** once actually screenshotted:
|
||
with exactly 3 badges, the 2-per-row-plus-1-wrapped layout put the lone
|
||
third badge off to one side, aligned under neither badge above it —
|
||
read as "durcheinander"/disorganized rather than clean. Back to the
|
||
inner-shrink-wrap single-column approach (strict 1-per-row below `lg:`,
|
||
which stays visually tidy regardless of badge count). **Lesson:**
|
||
`flex-wrap` is a real, correct fix for the alignment/overflow problem,
|
||
but an odd item count wrapping into a partial last row is its own
|
||
separate aesthetic risk, worth a real screenshot at the exact width
|
||
where the wrap count changes before trusting it as final. `Footer.tsx`'s
|
||
legal-links row was left on its plain `lg:` fix throughout — never
|
||
asked to change, and its content (nowrap links, not title/description
|
||
pairs) doesn't have the same "which items share a line" concern.
|
||
|
||
**Also same day: `NewsletterModal.tsx`'s close button** was `position:
|
||
absolute` inside the dialog's own `overflow-y-auto` scroll container, so
|
||
scrolling the modal's content scrolled the close button away with it.
|
||
Switched to `position: sticky` (`sticky top-6 ml-auto mr-6 -mb-6`) so it
|
||
stays pinned to the top-right corner of the visible (scrolled) area —
|
||
see the skill's Gotcha 23.
|
||
|
||
## Tests
|
||
|
||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||
needed) — no test infrastructure existed in this repo before; started with
|
||
the pure logic most likely to silently produce wrong numbers on a live
|
||
order, not attempted exhaustive coverage:
|
||
|
||
- **`app/lib/__tests__/cartTotals.test.ts`** — subtotal/discount/shipping
|
||
math (`computeSubtotal`, `computeCartTotals`), incl. the fixed-discount
|
||
clamp and `compareAtPrice`-based savings display being independent of
|
||
the discount-code math.
|
||
- **`app/lib/__tests__/bundleContents.test.ts`** — `describeBundleContents()`,
|
||
extracted out of `app/api/checkout/route.ts` into its own module
|
||
(`app/lib/bundleContents.ts`) specifically so it's importable from a
|
||
test — Next.js `route.ts` files only allow HTTP-method (+ a few config)
|
||
exports, not arbitrary named ones.
|
||
|
||
The original invoice's `isPaidImmediately()`/`groupByTaxRate()` and the
|
||
Gutschrift/Stornorechnung money math (`resolveLineItems()`,
|
||
`groupByTaxRate()`) both live in `@einfach-produktiv/invoicing` now (see
|
||
"Invoice PDFs" above), not in this repo — their tests moved with them
|
||
into that package's own `src/__tests__/`, run via that package's own
|
||
`vitest`, not this repo's `test:unit`. The Payload-side `orders.ts`
|
||
field-lock security logic is still tested in the **Payload backend's**
|
||
own `test:unit` — see that repo's README's own "Tests" section, since
|
||
that's where that logic actually lives.
|
||
|
||
## 2026-07-30 changes
|
||
|
||
**Blog posts can have multiple categories.** `BlogPost.categories` is now
|
||
`string[]` (was a single `category: string`), joined with `", "` wherever
|
||
a single category used to render (`/blog`, `/blog/[slug]`,
|
||
`LivePostContent.tsx`, `components/Blog.tsx`). Backend field is
|
||
`Posts.categories`, a `hasMany` relationship — see the Payload backend's
|
||
own README.
|
||
|
||
**Checkout's "Abweichende Lieferadresse" gained Firma + Kontakt-E-Mail/
|
||
Telefon** (all optional) — handed to the shipping carrier, not used for
|
||
any customer communication (that stays the account email above). Backed
|
||
by new `Orders.shipping*` fields with no shipping-side `vatId` (billing-
|
||
only concept, deliberately not mirrored).
|
||
|
||
**The customer profile (`/konto/profil`) can now store its own shipping
|
||
address**, mirroring the checkout's own "Abweichende Lieferadresse"
|
||
section field-for-field (same checkbox pattern, same fields incl. the new
|
||
Firma/Kontakt fields above) — prefills the checkout section for a
|
||
returning customer instead of always starting blank. Backed by
|
||
`Customers`' new "Lieferadresse" tab in the Payload backend.
|
||
|
||
**Products can have an optional SKU** even without variants
|
||
(`Products.sku` — variants already had their own). Snapshotted onto each
|
||
order item at checkout (`app/api/checkout/route.ts` — variant SKU takes
|
||
precedence over the product's own) and shown as "Art.-Nr." on the invoice
|
||
PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`.
|
||
|
||
**`@einfach-produktiv/invoicing` bumped to 0.2.7** for the SKU +
|
||
shipping-contact rendering above — re-run `npm install
|
||
@einfach-produktiv/invoicing` after pulling if your local `node_modules`
|
||
predates this.
|
||
|
||
Full plan for an n8n-driven blog-post content-automation pipeline (not yet
|
||
built) lives in the Payload backend repo's `docs/blog-automation-plan.md`,
|
||
not duplicated here since it changes independently of this frontend.
|
||
|
||
**Switching an unpaid Überweisung order to Stripe.** New "Zahlungsart
|
||
ändern" button on `/konto/bestellungen/[orderNumber]`, shown when an order
|
||
is still `paymentProvider: 'manual'`, `status: 'received'`,
|
||
`paymentStatus` not yet `'paid'`, and an active Stripe payment method
|
||
exists. `POST /api/account/orders/[orderNumber]/switch-to-stripe` creates
|
||
a real PaymentIntent (`app/lib/payments`, same code checkout itself uses)
|
||
and hands it to the backend's `switchPaymentToStripeEndpoint`. Reuses
|
||
`PaymentStep` (checkout's own Stripe collection UI) and
|
||
`/checkout/verarbeitung`'s polling page — both now take a
|
||
`returnContext`/`context` prop/param so payment confirmation lands back
|
||
on the order page instead of clearing the cart and redirecting to
|
||
`/bestellbestaetigung`, which would be wrong for an order that was
|
||
already placed and confirmed.
|
||
|
||
Also: a payment status badge (Offen/Bezahlt/…) next to the existing
|
||
fulfillment status badge, on both the order list (`/konto/bestellungen`)
|
||
and the detail page — "Offen" renders in the same red/subtle-background
|
||
style as a failed status, not the neutral grey the fulfillment status
|
||
badge uses for its own "in progress" states, since an unpaid order is
|
||
worth visually flagging. A one-line mention of the switch option was
|
||
added to the Vorkasse unpaid notice in the order-confirmation email,
|
||
shown only when `hasOnlinePaymentOption` is true (an active Stripe method
|
||
actually exists).
|
||
|
||
**Fixed the email-preview page** (`/email-preview/[type]`) — it rendered
|
||
the email HTML (a full `<body>...</body>` fragment) via
|
||
`dangerouslySetInnerHTML` into a plain `<div>`, nesting it inside the
|
||
page's own already-existing `<body>` — invalid HTML the browser silently
|
||
mangled, which is why the preview looked broken (wrong background/
|
||
padding/font). Now renders via `<iframe srcDoc>`, giving the email its
|
||
own real document context. Also added a second sample fixture
|
||
(`SAMPLE_ORDER_MANUAL`) with a toggle, since `SAMPLE_ORDER` alone always
|
||
had `isManualPayment: false` — the Vorkasse/Überweisung branch (and its
|
||
new switch-option mention) was never previewable in the admin at all
|
||
before this.
|
||
|
||
**Follow-up same day: switch-to-stripe eligibility narrowed.**
|
||
`canSwitchPayment` (page.tsx) and the `switch-to-stripe` route's own
|
||
re-check both changed from `paymentStatus !== "paid"` to an explicit
|
||
allowlist (`"pending"` or `"not_applicable"`) — see the Payload backend's
|
||
own README for why the blocklist form was too permissive. No visible
|
||
behavior change for the normal case, just closes an edge case
|
||
(`"failed"`/`"refunded"`/`"partially_refunded"` on a manual order — not
|
||
realistic today, but not meaningful states to switch *from* either).
|
||
|
||
**Two more same-day fixes:**
|
||
- `customerOrderAction()` now only offers "Rücksendung anfragen" once an
|
||
order is actually `delivered`, not already at `shipped` — a (partial)
|
||
return before the package has even arrived doesn't make sense yet. The
|
||
backend's own `CUSTOMER_ALLOWED_TRANSITIONS` still technically permits
|
||
`shipped` → `return_requested` too (an extensive existing test suite is
|
||
built around that as its base fixture, see the Payload backend's
|
||
README) — this only narrows what the UI itself offers, a stricter
|
||
subset of what the backend already allows, not a security boundary
|
||
being loosened.
|
||
- `/impressum`'s "Angaben zum Anbieter" email (`AnbieterAngaben.tsx`) was
|
||
plain text, not a `mailto:` link — the only email address on the whole
|
||
site that wasn't clickable, since this one section is rendered
|
||
straight from `company-settings` fields rather than through the CMS
|
||
richText renderer (which already links emails/URLs correctly).
|
||
|
||
**New `sendPaymentSwitchedEmail()`** (`lib/orderEmail.ts`) — sent instead
|
||
of `sendOrderConfirmationEmail()` when `confirmPaymentEmail.ts` sees
|
||
`order.paymentSwitchedAt` set (a payment confirmation that came from
|
||
switching an existing Überweisung order to Stripe, not a fresh gated
|
||
checkout — see the Payload backend's own README on
|
||
`switchPaymentToStripeEndpoint`). Same shape as the order-status emails
|
||
(icon + admin-editable heading/bodyText + "Bestellung ansehen" button via
|
||
`renderOrderStatusHtml`), no item table, but **does** re-attach a freshly
|
||
generated invoice PDF — same `invoiceNumber` as always (never re-issued
|
||
for a switch), but `paymentMethodTitle` now reflects the actually-
|
||
confirmed instrument, which changes `@einfach-produktiv/invoicing`'s own
|
||
`isPaidImmediately()` check from the Vorkasse notice to "✓ Bereits
|
||
beglichen", so the customer's copy of "their invoice" should reflect
|
||
that. `buildInvoiceAttachment()` was extracted out of
|
||
`sendOrderConfirmationEmail()` so both senders share the exact same PDF-
|
||
generation call instead of duplicating that ~40-line object literal.
|
||
Default fallback copy (used whenever no admin template is saved/active)
|
||
matches the existing brand voice (`sendOrderConfirmationEmail`'s own
|
||
"Bestellt! Deine Ruhe kann kommen 🎉" fallback) rather than a flat
|
||
system-notice tone: *"Erledigt! Deine Zahlung ist da 🎉" / "Deine Zahlung
|
||
ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch
|
||
weiter, du musst dich um nichts mehr kümmern."* New
|
||
`payment-method-switched` `EmailTemplateType`, added to
|
||
`/email-preview`'s valid types too.
|
||
|
||
**Bumped `@einfach-produktiv/invoicing` to 0.2.8** — fixes a font-path
|
||
bug that broke every invoice/correction-invoice PDF render *inside the
|
||
Payload backend* specifically. See that package's own README for the
|
||
Turbopack asset-hashing root cause. (This turned out to be an
|
||
incomplete picture — see "Cart bugs" below: 0.2.8 itself broke this
|
||
frontend's own client bundle, just not in a way anyone noticed yet at
|
||
the time this was written.)
|
||
|
||
## Order list mobile grid (2026-07-30)
|
||
|
||
`/konto/bestellungen`'s order cards used plain `flex flex-wrap` for their
|
||
6 fields (Bestellnummer, Datum, Artikel, Status, Zahlungsstatus,
|
||
Gesamtbetrag), which sized each field to its own content width — so e.g.
|
||
"Status"/"Zahlungsstatus" started at a different x position from one
|
||
order card to the next depending on how wide that particular badge label
|
||
happened to be. Replaced with a single CSS Grid per card
|
||
(`grid grid-cols-2 min-[500px]:grid-cols-4`), since a grid's column
|
||
tracks are fixed by the container width (identical on every card
|
||
regardless of content) rather than by content width.
|
||
|
||
Went through several rounds of visual feedback:
|
||
- Below 500px there just isn't room for 4 tracks without badge labels
|
||
like "In Bearbeitung" overflowing their column, so it drops to 2
|
||
columns there. The 6 fields in DOM order already tile cleanly into 3
|
||
rows of 2 (Bestellnummer/Datum, Artikel/Status,
|
||
Zahlungsstatus/Gesamtbetrag) without any reordering.
|
||
- Bestellnummer/Datum deliberately stay at `col-span-1` in both the
|
||
2-column and 4-column layouts, side by side even below 500px — a
|
||
brief `col-span-2` experiment (own full-width row each, to avoid the
|
||
order number wrapping on very narrow phones) was reverted since it
|
||
read worse than the occasional wrap.
|
||
- Artikel forces `col-start-1` so it wraps to its own row in the
|
||
4-column layout instead of Grid's auto-placement flowing it into the 2
|
||
cells left empty in row 1 after Bestellnummer/Datum (a harmless no-op
|
||
in the 2-column layout, where it's already first in row 2 anyway).
|
||
- The breakpoint moved twice during tuning — first sm (640px) → md
|
||
(768px) → lg (1024px) while the intent was still "keep 2 columns as
|
||
long as possible," then flipped entirely to "stay at 4 columns as long
|
||
as possible" once the actual goal was clarified, then settled on a
|
||
content-driven `min-[500px]` (started at 400px, moved to 500px) once a
|
||
real device screenshot showed 4 columns overflowing on a narrow phone.
|
||
- `OrderStatusBadge`/`PaymentStatusBadge` gained `self-start` — as flex
|
||
children of a `flex flex-col` parent (default `align-items: stretch`),
|
||
the badge `<span>` was stretching to the parent's full width despite
|
||
being `inline-flex` itself (flex items are blockified, so `stretch`
|
||
still applies); `self-start` keeps the badge background only as wide
|
||
as its label.
|
||
|
||
## Cart bugs (2026-07-30)
|
||
|
||
Two independent bugs, both reported together as "cart count keeps going up
|
||
after every logout/login, and `/cart` won't open":
|
||
|
||
**1. Quantities doubled on every logout/login cycle.** `LogoutButton.tsx`
|
||
never cleared the local cart (`ep_cart` in `localStorage`), and
|
||
`mergeServerCartIntoLocal()` (`lib/cart.ts`, called after every login) adds
|
||
the server-side cart's quantities into the existing local ones
|
||
(`existing.qty += qty`) rather than replacing them — correct behavior for
|
||
folding in genuine guest-cart additions, wrong once `CartSync.tsx` has
|
||
already mirrored the local cart to the server. Since both sides held the
|
||
same quantities at logout time, every login added them together, doubling
|
||
the total each cycle. Fixed by clearing the local cart on logout — the next
|
||
login's merge then starts from empty (or only genuinely new guest-session
|
||
items) instead of re-adding already-synced quantities. `readCart()`/
|
||
`getCart()` also gained an `Array.isArray()` check after `JSON.parse`
|
||
(previously only guarded against parse *syntax* errors, not the parsed
|
||
value being some other shape) as a general defensive hardening, though this
|
||
turned out not to be the `/cart` crash's actual cause — see below.
|
||
|
||
**2. `/cart` couldn't open: `@einfach-produktiv/invoicing`'s `fonts.ts`
|
||
crashed the client bundle.** Unrelated to bug 1 — this frontend's own
|
||
`CartContent.tsx` imports `computeTaxBreakdown` from
|
||
`@einfach-produktiv/invoicing`'s barrel `index.ts`, which also re-exports
|
||
`invoicePdf.tsx`, which imports `fonts.ts`. The same-day v0.2.8 fix in that
|
||
package (see the entry above) made `fonts.ts` call `fileURLToPath` at
|
||
module scope unconditionally — fine in Node.js, but `node:url`'s
|
||
`fileURLToPath` isn't a real function in a browser bundle's polyfilled
|
||
shim, so **every** client bundle that transitively reached this module
|
||
crashed on module evaluation (`Uncaught TypeError: fileURLToPath is not a
|
||
function`), including `/cart`'s entire client-rendered page. Fixed
|
||
upstream in `@einfach-produktiv/invoicing` 0.2.9 (see that package's own
|
||
README) by branching on `typeof window === "undefined"` — browser keeps
|
||
the original `new URL(..., import.meta.url)` idiom, Node.js keeps the
|
||
`fileURLToPath` resolution. Re-ran `npm install @einfach-produktiv/invoicing`
|
||
here to pick it up.
|
||
|
||
## Deployment
|
||
|
||
- **Dockerfile**: 3-stage build (`deps` → `builder` → `runner`) with
|
||
BuildKit cache mounts, producing a ~100 MB standalone image. This is the
|
||
reference `Dockerfile` copied when scaffolding new projects on this VPS
|
||
(see the infra repo's new-project workflow).
|
||
- Deploys via `git push` → Gitea webhook → Caddy `/deploy/einfach-produktiv`
|
||
bridge → Coolify API → rebuild + restart. Full mechanics in
|
||
`~/dev/README.md`.
|
||
|
||
## Wishlist, search, filters, feature toggles (2026-07-30 evening)
|
||
|
||
A cluster of new features, all gated behind new `CompanySettings`
|
||
booleans (Payload admin → Company Settings → **Features** tab, all off
|
||
by default — each feature stays entirely invisible in the frontend
|
||
until explicitly switched on): `wishlistEnabled`, `searchEnabled`,
|
||
`shopFilterEnabled`, `blogFilterEnabled`, `orderFilterEnabled`.
|
||
|
||
**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 on a page 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, a 3rd icon there risks the nav-
|
||
overflow bug class documented in the `figma-to-nextjs` skill), and
|
||
`/konto/merkliste` (a Client Component grid — `MerklisteGrid.tsx` — so
|
||
removing an item disappears immediately, unlike a plain Server
|
||
Component render which wouldn't react to the client-side toggle at all).
|
||
`Product` gained a `numericId` field (the raw Payload id) alongside its
|
||
existing slug `id`, since `wishlist-items.product` is a real numeric
|
||
relationship, unlike the cart/checkout's slug-keyed "commerce id".
|
||
|
||
Found and fixed a real race condition in `useWishlist.ts`: the "refetch
|
||
me" event used to fire immediately after the optimistic local update,
|
||
before the POST request was even sent — another instance's resulting
|
||
refetch could beat the actual server write, cache the pre-toggle list,
|
||
and never get told to refetch again (the Navbar count stayed one
|
||
behind). The event now only fires after the request settles.
|
||
|
||
**Standalone registration.** Until now an account only ever got created
|
||
inline during checkout. That stopped making sense the moment a
|
||
wishlist gave accounts a reason to exist independent of a purchase —
|
||
new `/konto/registrieren` (+ `RegisterForm.tsx`, reusing the existing
|
||
`/api/account/register` route). `LoginForm.tsx` now also honors a
|
||
`?redirect=` param (previously ignored, always landing on
|
||
`/konto/bestellungen`) and links to the new page instead of implying
|
||
"only via checkout".
|
||
|
||
**Instant search.** New `/api/search` route — plain Payload
|
||
`where[...][contains]` queries (Postgres `ILIKE`) against Products +
|
||
Posts, not a real search index (Meilisearch/Algolia); fine at this
|
||
catalog's current size, worth upgrading once it grows past ~20 products
|
||
(see the e-commerce SOTA gaps memory note). `SearchOverlay.tsx`
|
||
(debounced 250ms, grouped results) + a Navbar search icon (same
|
||
`hidden sm:` reasoning as the wishlist icon).
|
||
|
||
**Filters, everywhere URL-search-param-driven** (shareable/bookmarkable,
|
||
no client-side-only state):
|
||
- `/konto/bestellungen` — status/paymentStatus/year, via 3
|
||
`CustomSelect` dropdowns (`OrderFilters.tsx`). Went through two
|
||
earlier UI iterations first: a wall of filter chips (too cluttered,
|
||
too much vertical space) and then plain native `<select>`s (a native
|
||
select's open options popup can't be styled at all — looked
|
||
completely off-brand) before landing on `CustomSelect.tsx`.
|
||
- `/blog` — category toggle chips (`buildCategoryHref()`), plain
|
||
server-rendered `<Link>`s, no client component needed since the page
|
||
already fetches every published post in one request.
|
||
- `/shop` — a price min/max range (`PriceRangeFilter.tsx`) — preset
|
||
toggle buckets were tried first and reverted ("keine toggle badges").
|
||
A fuller sidebar-with-drag-slider layout was requested as a follow-up,
|
||
not yet built as of this writing.
|
||
|
||
**`CustomSelect.tsx`** (`app/components/`) — a fully custom-styled
|
||
dropdown (own trigger + own `role="listbox"` options panel, keyboard
|
||
nav, click-outside-to-close), built to replace native `<select>`s
|
||
wherever their un-stylable options popup matters. Also used for
|
||
checkout's two country pickers now (`includeAllOption={false}` — a
|
||
country is always genuinely selected, no "clear" state like the
|
||
filter-dropdown use case; `fullWidth` — matches the surrounding w-full
|
||
form fields instead of the filter bar's shrink-to-fit sizing).
|
||
|
||
**Assorted same-evening bugfixes:**
|
||
- Blog category filter bar was invisible (wrapped in `<Reveal>`, which
|
||
sits right at the hero's bottom edge — exactly the
|
||
`whileInView(margin:"-80px")` trap `Reveal.tsx`'s own comment
|
||
documents: an element already visible without scrolling can
|
||
permanently never register as "entered view"). Now a plain `div`.
|
||
- Then found rendering *behind* the featured-post card instead (that
|
||
card pulls itself up `-mt-8`/`z-10` to overlap the *hero's* bottom
|
||
edge — its original design — but the filter bar now sat where that
|
||
pull landed). Given `relative z-20` to stay above it.
|
||
- `AddToCartInlineButton`'s `className` prop *replaces* its whole
|
||
default styling (`?? default`, not a merge) — `/konto/merkliste`
|
||
passed `className="w-full"` and lost all the button's actual styling
|
||
as a result. Removed the override (the default is already `w-full`).
|
||
|
||
**How to apply if you're reading this cold:** every one of these
|
||
features requires the matching `CompanySettings` toggle to actually be
|
||
switched on before it shows up anywhere in the frontend — if a feature
|
||
"isn't showing," check that first.
|
||
|
||
## Wishlist "already purchased" marker (2026-07-31)
|
||
|
||
Decided against auto-removing a wishlist item once the customer buys it —
|
||
a customer often wishlists something specifically *to* buy it again
|
||
(gifts for multiple people, anything they'd repurchase), so silently
|
||
removing it right when they'd next want it defeats the point. Instead,
|
||
`/konto/merkliste` (`MerklisteGrid.tsx`) marks it: dimmed image + a
|
||
"Gekauft am [date]" badge (replaces the "Ausverkauft" badge when both
|
||
would apply — already-owning it matters more than a restock notice).
|
||
Removal stays entirely manual, same `WishlistButton` as always.
|
||
|
||
- **`WishlistItem.purchasedAt`** (`app/lib/customerAuth.ts`) — never
|
||
persisted on the backend's `wishlist-items` collection (its `update`
|
||
access is hard-disabled by design); derived fresh on every
|
||
`getWishlist()` call by cross-referencing the customer's own orders
|
||
(`getPurchasedVariantMap()`), matched on the exact `(product, variant)`
|
||
pair a wishlist row represents. Excludes `cancelled`/`returned` orders
|
||
— an aborted or refunded order doesn't mean the customer actually owns
|
||
the item.
|
||
- Flows through `useWishlist.ts`'s existing item shape/cache/broadcast
|
||
mechanism unchanged — this is purely an added read-only field, not a
|
||
new toggle or a second collection.
|
||
|
||
## DHL checkout integrations (2026-07-31)
|
||
|
||
Three checkout-facing pieces added, each invisible unless the tenant's
|
||
backend `dhl-settings` has the matching toggle on (checked indirectly —
|
||
the calls below 404 gracefully when a tenant hasn't activated a given DHL
|
||
sub-integration, same "no half-built UI" convention as the feature
|
||
toggles above). Full backend-side documentation:
|
||
`docker/payload`'s `src/lib/shipping/README.md`.
|
||
|
||
- **`app/lib/shippingDhl.ts`** — thin client for the backend's 2 public
|
||
DHL endpoints (Postnummer validation, DataFactory address autocomplete).
|
||
Own copy, no shared package — same convention as `app/lib/tracking.ts`.
|
||
- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and
|
||
**`app/api/checkout/autocomplete-address/route.ts`** — Next.js API
|
||
routes proxying to the backend. Required because `CheckoutContent.tsx`
|
||
is a Client Component and DHL credentials are tenant-specific (live in
|
||
Payload) — unlike VIES (`validate-vat/route.ts`), the browser can never
|
||
call Payload's DHL endpoint 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` (mirrors `handleVatIdBlur`'s shape/status-state
|
||
pattern) 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 the new `getMediaUrlById()` in `app/lib/payload.ts` — the order
|
||
fetch itself stays `depth=0` for unrelated reasons, see
|
||
`getCustomerOrderDetail`'s own comment).
|
||
|
||
## 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
|