From a077adea0f5850e0ddddcb732ad32ac1b6dce5f0 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 18:06:48 +0000 Subject: [PATCH] Rewrite README as current-state reference, drop changelog narrative Removes all dated section headings (2026-07-24 through 2026-07-31) and inline dated asides, folding still-true facts into the evergreen structure (Pages, Cart & checkout, Orders & customer accounts, etc.). Also fixes references to app/lib/vies.ts/vatId.ts/tracking.ts, which moved into @einfach-produktiv/invoicing. Git history is the actual changelog. 2375 -> 1798 lines. Co-Authored-By: Claude Sonnet 5 --- README.md | 1301 +++++++++++++++-------------------------------------- 1 file changed, 362 insertions(+), 939 deletions(-) diff --git a/README.md b/README.md index f0a8f88..080ccfd 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,60 @@ not in a committed `.env`. | `/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/login`, `/konto/registrieren` | Customer login / standalone registration | | `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) | | `/konto/profil` | Profile/address editing + password change | +| `/konto/merkliste` | Wishlist (when `wishlistEnabled`, see "Feature toggles" below) | | `/challenge` | "Mini-Challenge" tool | | `/todo-cards` | "Todo-Karten" tool | | `/newsletter` | Newsletter signup | | `/versand` | Shipping policy (timeframes, costs) | | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) | +### Responsive design + +The site uses a fluid, `clamp()`-based responsive system (`app/lib/fluid.ts`, +tokens hand-authored into `app/globals.css`'s `@theme`/`:root` blocks) that +scales continuously between a 640px floor and a 1440px ceiling — most +components need no structural breakpoint at all. Where a genuine +structural reflow is needed (grid-to-stack, flex-direction switches), the +site uses a single `sm:` (640px) breakpoint, with a documented handful of +exceptions still gated on `lg:` (1024px) for real fixed-width content: +the Cart/Checkout two-column split, the legal-page table-of-contents +sidebar, the newsletter/todo-cards hero and benefits sections, +`Footer.tsx`'s legal-links row, and `TrustRow.tsx`. + +Below `lg:` (1024px), the Navbar's hamburger opens a fullscreen nav panel +(circular reveal animation, a sibling of `
` rather than a child so +the header's own `backdrop-blur` doesn't break the panel's +fixed-to-viewport positioning) instead of an inline dropdown; the +wishlist/search icons are hidden below `sm:` (640px) so Account + Cart +stay the only always-visible icons on narrow phones (see "Feature +toggles" below). `Navbar.tsx` itself keeps its own separate `md:`/`lg:` +3-tier scheme for its nav links — horizontal nav-link overflow is a +different failure mode than the vertical grid/flex reflows the rest of +the site handles with `sm:`. + +### SEO & structured data + +Every page can carry real per-page metadata — `/blog/[slug]`'s +`generateMetadata()` reads a post's own `seoTitle`/`seoDescription`/`seoImage` +(see the `posts` collection below); `app/layout.tsx`'s `generateMetadata()` +reads `getSeoSettings()` (ISR-cached, backed by `company-settings`) for +the site-wide fallback. + +JSON-LD structured data (`app/lib/structuredData.ts`, no headless +external validation step, just direct grepping of the rendered +`application/ld+json` script) is emitted for the pages Google gives rich +results for: an `Organization` schema site-wide in the root layout (from +`getCompanySettings()`, excluding sensitive fields like `iban`/`bic`, +with a stable `@id` that other schemas link back to rather than repeating +the full object), a `Product` schema on `/todo-cards` (the one page with +its own dedicated single-product URL — deliberately not on `/shop`'s +grid, since most products there have no individual detail page to point +a `Product`'s `url` at), and a `BlogPosting` schema on every +`/blog/[slug]` page. + ## Content backend: Payload CMS This app is one **tenant** in a shared, multi-tenant Payload instance also @@ -104,9 +149,9 @@ 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) | +| `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), `sku` (optional, even without variants — variants carry their own; snapshotted onto each order item at checkout and shown as "Art.-Nr." on the invoice PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`) | | `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below | -| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `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`) | +| `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `categories` (`hasMany` relation to `categories` — a post can have several, joined with `", "` wherever rendered), `excerpt`, `thumbnail`, `content` (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via `app/components/RichText.tsx`'s `@payloadcms/richtext-lexical/react` renderer with custom `JSXConverters`), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`), `seoTitle`/`seoDescription`/`seoImage` (each falls back to `title`/`excerpt`/`thumbnail` when empty, consumed by `/blog/[slug]`'s `generateMetadata()`) | | `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) | | `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `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` | @@ -120,7 +165,7 @@ explains what does have access instead. | `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 | +| `email-templates` | Editable subject/heading/body/footer for all 10 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`payment-method-switched`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button | | `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below | All of the above except `company-settings`, `media`, `users`, `tenants` @@ -141,7 +186,7 @@ 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 +numbering schemes (`number-ranges`), all 10 email wordings (`email-templates`, with Live Preview), and invoice seller data, bank details, and VAT rate (`company-settings` — also what every email's legal footer is sourced from). What still requires a code change: @@ -253,10 +298,8 @@ check against Payload's public API, unlike most content on this site. `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 +- **Add-to-cart is capped at actual remaining stock.** + `Product`/its variants carry a real `maxQty` (`app/lib/payload.ts`'s `mapPayloadProduct()` — `null` when unlimited, i.e. backorder allowed or inventory untracked; a deliberate, narrow exception to that function's own "the public API has no reason to leak exact stock counts" comment, @@ -412,67 +455,18 @@ 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. +**Stripe is live in production for Kreditkarte.** `payment-methods` rows: +Kreditkarte → `provider: 'stripe'`, active; PayPal → `provider: 'stripe'`, +prepared but not yet activated on the Stripe account itself; +"Überweisung (Vorkasse)" → `provider: 'manual'`; "Sofortüberweisung" +prepared as `provider: 'stripe'` but `active: false` pending a logo (see +the backend's own README). `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, +`NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, and `PAYMENT_WEBHOOK_SECRET` are set +in both Coolify and the backend's `docker/.env` (the backend's own copy of +`STRIPE_SECRET_KEY` is used only for `stripeRefund.ts`, Storno/Gutschrift +refunds). Going from test-mode keys (`sk_test_...`) to live-mode keys +(`sk_live_...`) is a straight swap of the same three Coolify variables +plus a new live-mode webhook endpoint (new `whsec_...`) — no code change. ### VAT display @@ -506,28 +500,26 @@ 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. +**None of this renders at all for a Kleinunternehmer tenant** — see +"Kleinunternehmerregelung" below for the full list of touched spots and +why some read a live setting and others a persisted per-order snapshot. -**Vorkasse instruction in the confirmation email (2026-07-25)** — the -invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own +**Vorkasse instruction in the confirmation email.** The invoice PDF +already shows this (see `@einfach-produktiv/invoicing`'s own `unpaidNoticeText`), but a customer often only glances at the email body -itself, not the attached PDF. `OrderConfirmationData` gained a required -`isManualPayment: boolean` field — **explicitly set by each caller** +itself, not the attached PDF. `OrderConfirmationData`'s required +`isManualPayment: boolean` field is **explicitly set by each caller** (checkout route's manual branch: `true`; the Stripe webhook path: always `false`, since only a *paid* Stripe order ever reaches that send at all), deliberately **not** derived from `paymentMethodTitle` inside `emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte", "Überweisung (Vorkasse)", ...) is exactly the kind of thing a -payment-methods rename 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. +payment-methods rename could silently break (see `isPaidImmediately()` in +the invoicing package). When `isManualPayment` is true, `vorkasseNotice()` +renders a full-width block (own row, `margin-top: 20px` — not squeezed +into the Gesamtsumme table) naming the bank details +(`CompanySettings.bankName`/`iban`/`bic`) and the "processed within 1–2 +business days after payment received" note. ### Checkout state persistence @@ -550,22 +542,59 @@ 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`, +street/Packstation/PLZ/Ort/Land, plus optional Firma and +Kontakt-E-Mail/Telefon handed to the shipping carrier only, never used for +customer communication — that stays the account email) — that's the only +place Packstation delivery is offered at all. When used, the order's +original address fields stay the **billing** address (used for the +invoice's "An" block regardless), and the `shipping*`-prefixed fields +(`hasDifferentShippingAddress`, `shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/ `shippingStreet`/`shippingPackstationNumber`/`shippingPostNumber`/ -`shippingZip`/`shippingCity`/`shippingCountry` — 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. +`shippingZip`/`shippingCity`/`shippingCountry`/`shippingCompany`/ +`shippingContactEmail`/`shippingContactPhone` — mirrored 1:1 on the +Payload `orders` collection, see the Payload README; there's no +`shippingVatId` — VAT ID is a billing-only concept, deliberately not +mirrored) determine where the order actually ships. `/api/checkout/route.ts` +validates the override the same way it already validated the primary +address (required fields, street-xor-Packstation depending on the chosen +delivery method). The invoice PDF shows a third "Lieferadresse" address +block alongside Von/An when set (see "Invoice PDFs" below); +`/konto/bestellungen/[orderNumber]` shows both addresses too, relabeling +the first one "Rechnungsadresse" instead of "Lieferadresse" only once +there's an actual second address to distinguish it from. `/konto/profil` +can store this same deviating-shipping-address shape (same checkbox +pattern, same fields) as a saved default, prefilling checkout's own +section for a returning customer instead of always starting blank. + +### DHL checkout integrations + +Three checkout-facing pieces, each backed by the Payload backend's +tenant-configurable `dhl-settings` and gracefully absent (the underlying +calls 404) whenever a tenant hasn't activated the matching DHL +sub-integration — see `docker/payload`'s `src/lib/shipping/README.md` for +the backend side. + +- **`app/lib/shippingDhl.ts`** — a thin client for the backend's two + public DHL endpoints (Postnummer validation, DataFactory address + autocomplete); its own small copy, not part of the shared invoicing + package. +- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and + **`app/api/checkout/autocomplete-address/route.ts`** — Next.js API + routes proxying to the backend, since `CheckoutContent.tsx` is a Client + Component and DHL credentials are tenant-specific (live in Payload) — + the browser never talks to Payload's DHL endpoints directly. +- **`app/checkout/components/AddressAutocomplete.tsx`** wraps the billing + and shipping street inputs with a debounced DHL DataFactory suggestion + dropdown; selecting a suggestion also fills zip/city. +- **Postnummer live validation** — `CheckoutContent.tsx`'s + `handlePostNumberBlur` (same shape/status-state pattern as the VAT-ID + blur check) checks the Packstation Postnummer against DHL on blur, once + the existing format check (6–10 digits) already passes. +- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows a + "Retourenschein herunterladen" link once the backend has generated a DHL + return label for that order (`order.dhlReturnLabelMedia`, resolved via + `getMediaUrlById()` in `app/lib/payload.ts`). ### Destination countries (Payload-configurable) @@ -688,10 +717,9 @@ builds on. `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 +set — `@einfach-produktiv/invoicing`'s `buildTrackingUrl(carrier, trackingNumber)`/ +`CARRIER_LABELS` (imported from the package's main barrel) are shared with +the Payload backend, so the link an admin sees generated in the `order-shipped` email matches exactly what a customer sees here. Falls back to plain (non-linked) text for `carrier: 'other'`, which has no known URL pattern. @@ -738,13 +766,8 @@ inbox, not only in `/konto/bestellungen`. 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. + without actually needing a monorepo — see that package's own README + for the full renderer/formatter surface. - **`InvoiceDocument`** — a `@react-pdf/renderer` `Document`, not HTML-to-PDF or a headless browser (Puppeteer/Chromium would be a heavier footprint on a VPS already running several other containers). @@ -763,8 +786,7 @@ inbox, not only in `/konto/bestellungen`. 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. + background, no alternating white/tinted zebra striping. - **"Bereits beglichen" confirmation**: shown next to the summary card whenever `order.paymentMethodTitle` is anything other than `"Überweisung"` (bank transfer) — Kreditkarte and PayPal both settle at @@ -772,63 +794,48 @@ inbox, not only in `/konto/bestellungen`. 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. + method is added in Payload). Rendered as plain green text, not a tinted + pill/badge box. This one is genuinely conditional on the order's own + payment method — unlike the bank-details line below, which prints + unconditionally. - **Bank details**: `company-settings.bankName`/`.iban`/`.bic` — `iban`/ `bic` structured and independently format-validated (uppercased/trimmed on save too, so "de123..." doesn't fail validation just for being lowercase — same normalization `discount-codes.code` already used), `bankName` stays free text since there's no fixed format to validate a - bank's display name against. 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 → + bank's display name against — structured, discrete PaymentMeans data + rather than a single free-text field, per EN16931. When `iban` or `bic` + is set, the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" + **always**, regardless of the order's payment method — a card/PayPal + customer might still want the seller's bank details for other reasons + (e.g. a refund). +- **Summary layout is a genuinely additive chain.** The summary card + reads Zwischensumme → Rabatt (if any) → Versand → a divider → Gesamt, + using the order's own raw `subtotal`/`discountAmount`/`shippingCost`/ + `total` fields directly — every row above the divider actually sums to + the number below it. The per-rate tax breakdown (from + `computeTaxBreakdown()`, which distributes discount/shipping + proportionally across each tax-rate group before computing net/tax, per + how ancillary costs are legally apportioned across rates) sits **below** + Gesamt as an "enthält X% MwSt.: Y €" annotation (one line per distinct + rate, informational — not part of the additive stack above it), the + same "contained within, not an extra deduction" framing + `VatBreakdown.tsx` uses on `/cart`/`/checkout` (see "VAT display" + above). Applies to both the original invoice and its Storno/Gutschrift, + which has its own equivalent chain (Zwischensumme → the discount + reversal, "Rabatt (entfällt)", a positive add-back since the original + discount no longer applies once everything's undone → Versand → Gesamt; a Gutschrift shows Gesamt alone, since it never reverses shipping/discount in the first place — see the Payload README's "How a Stornorechnung/Gutschrift relates to the original invoice"). When the - order is VAT-exempt (see "VAT exemption" below), this annotation 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). + order is VAT-exempt (see "VAT exemption" below), this annotation reads + "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead. +- **Netto row**: a dedicated "Netto" row sits between Gesamt and the + "enthält X% MwSt." annotation on every invoice this shop issues + (original, Storno, Gutschrift alike) — businesses read this directly + for their own input-tax deduction instead of computing Gesamt minus + MwSt by hand. Shown unconditionally, including on VAT-exempt orders + (net and Gesamt happen to be the same figure there). - **Product thumbnails**: each item row shows a small product image — resolved from the order-confirmation data's already-available `imageUrl` for the checkout-time attachment, or via @@ -899,9 +906,8 @@ inbox, not only in `/konto/bestellungen`. - **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 ... + `Padding` — a Stornorechnung/Gutschrift has its own sequence, distinct + from ordinary invoices). Both are assigned via a single atomic `UPDATE ... RETURNING` against Postgres (Payload's backend `numberRange.ts`), not a read-then-write across two separate calls — see the Payload README's "Number ranges" section for why that distinction actually matters for @@ -924,9 +930,8 @@ inbox, not only in `/konto/bestellungen`. 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 +- **E-invoicing (ZUGFeRD/EN16931).** Every invoice generated above is + actually a Factur-X-EN16931 hybrid PDF/A-3 (the same visual PDF, plus an embedded machine-readable `factur-x.xml`), for every order, not just B2B — see the shared package's own README for the full phased build (`@e-invoice-eu/core`, atomic invoice numbering, a self-hosted Mustang-CLI CI pipeline that @@ -935,12 +940,11 @@ inbox, not only in `/konto/bestellungen`. ### 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/ +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 +(`@einfach-produktiv/invoicing`'s `isValidVatId()`/`normalizeVatId()`, same EU-format regex as `company-settings.vatId` on the backend), persisted through `checkoutDraft.ts` like every other Card 1 field, and saved as a `Customers` profile default (`/konto/profil`) that prefills future @@ -952,7 +956,7 @@ download routes and the correction-invoice email. ### VAT exemption (innergemeinschaftliche Lieferung) -Built 2026-07-23. A cross-border EU B2B sale — Österreich is the +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 @@ -964,9 +968,9 @@ 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. +- **`@einfach-produktiv/invoicing/vies`** (server-only entrypoint) — + `checkVatIdViaVies()` calls the European Commission's public VIES REST + API (`POST .../check-vat-number`) directly, server-side only. - **`app/lib/vatExemption.ts`** — `destinationCountry()` picks the actual place the goods ship to (the shipping-address override's country when set, billing country otherwise — the exemption depends on where the @@ -976,12 +980,7 @@ turns out unregistered, the seller retroactively owes the VAT itself). 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` + questions.** `app/api/checkout/validate-vat/route.ts`/`app/api/checkout/route.ts` check any format-valid VAT ID against VIES regardless of destination (data quality — worth knowing whether it's real at all, same reasoning as `company-settings.vatId`'s own check below) — the exemption itself @@ -999,15 +998,14 @@ turns out unregistered, the seller retroactively owes the VAT itself). (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 + field re-focuses so the customer's attention returns there without + also selecting the whole existing value — a stray keystroke while just + glancing at the error shouldn't wipe out what was already typed. This + same "re-focus the failing field" behavior applies generically to *any* + checkout field that fails its own blur validation, not just this one — + see the bullet below. +- **Every other checkout field is blur-validated too** — inline red + error text appears the moment a field loses focus (required fields, email format, PLZ digit count per country, Packstation/Postnummer digit count), not only when the browser's native `pattern`/`required` validation kicks in at submit. The native attributes stay in place as a fallback for any field somehow @@ -1023,13 +1021,10 @@ turns out unregistered, the seller retroactively owes the VAT itself). 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. +- **Every fixed-length numeric field also hard-caps input length** — PLZ + (`maxLength` = the selected country's own digit count), USt-IdNr. + (`14`), Packstationnummer (`3`)/Postnummer (`10`, on the backend), on + top of their own `pattern`/`validate` format checks. - **`/bestellbestaetigung`** mirrors the same exempt-totals branch from the persisted `OrderSnapshot.vatExempt` flag (it otherwise re-derives totals live from the current catalog, which would show the wrong, VAT-inclusive @@ -1037,12 +1032,11 @@ turns out unregistered, the seller retroactively owes the VAT itself). ### 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. +`company-settings.kleinunternehmer` — a standing per-tenant setting +(Payload backend), not a per-order decision like the VAT exemption above +— when on, this tenant never charges VAT on anything, domestic or +cross-border. See the Payload README's own "Kleinunternehmerregelung" +section for the field/collection side; this one covers the frontend. - **`api/checkout/route.ts`** forces every item's `taxRatePercent` to `0` when `getCompanySettings().kleinunternehmer` is on — deliberately @@ -1103,7 +1097,7 @@ updating as the admin edits `sellerName`/address/`taxRatePercent`/ - **`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 + kind of document here, unlike the 10 email types. **Actually gated on `draftMode().isEnabled`** (calls `notFound()` otherwise), unlike `/email-preview` — this data includes a real bank IBAN/address once filled in, not just marketing email copy, so it must not render for an @@ -1131,25 +1125,19 @@ updating as the admin edits `sellerName`/address/`taxRatePercent`/ ## 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 +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. +checkout's `newsletterOptIn` checkbox — all sync to Brevo's contact list. - **`app/lib/brevo.ts`** — the only thing that talks to Brevo. `upsertNewsletterContact(email, source)` calls Brevo's - **double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation` - (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 + **double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation` — + this only ever *requests* a subscription; Brevo sends a confirmation + email (the template at `BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in template in Brevo's own UI) and only actually adds the contact to `BREVO_LIST_ID` once they click through. `source` (`"checkout"` | @@ -1166,7 +1154,7 @@ unsynced. 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` +- **Already-subscribed detection** — `doubleOptinConfirmation` itself gives no way to tell a brand-new signup apart from an already-confirmed contact re-submitting the form (verified directly: calling it twice for the same confirmed contact returns the identical @@ -1178,36 +1166,29 @@ unsynced. 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" &&

{error}

}`). + existing error state, not a success variant** — the form stays visible + with a small red note below it, exactly like every other inline + validation error (`useNewsletterSignup.ts` sets `status: "error"`, + `error: "Diese E-Mail-Adresse ist schon für unseren Newsletter + angemeldet."`). This error clears on the next interaction + (`handleEmailChange`/`handleConsentChange` both call a shared + `clearSubmitError()`), matching standard form-validation behavior + instead of sitting there until the next submit. - **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit state + on-blur validation + refocus-on-invalid-submit behind all four - forms (same "state of the art, simple" input-quality bar as checkout's - own fields). Each form keeps its own markup/visual style (`Newsletter`'s + forms. Each form keeps its own markup/visual style (`Newsletter`'s panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only - the logic is shared, not a one-size-fits-all component. The real-success - message text 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*). + the logic is shared, not a one-size-fits-all component; the + real-success message text (`successMessage`) also lives here rather + than hardcoded per form. +- `/api/newsletter/subscribe/route.ts` logs `upsertNewsletterContact()`'s + real failure `reason` server-side (`console.error`) while keeping the + customer-facing error generic ("Anmeldung ist fehlgeschlagen...") — + never leaks Brevo's internal error text, but stays diagnosable from the + server logs (e.g. a misconfigured `BREVO_LIST_ID`). - **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`, the single plain-email-format check shared by every newsletter form - *and* checkout's own email field (previously duplicated between - `CheckoutContent.tsx` and the subscribe route). + *and* checkout's own email field. - **`app/api/newsletter/subscribe/route.ts`** — validates email-format + consent server-side too (never trusts the client alone), then calls `upsertNewsletterContact()`. @@ -1222,56 +1203,39 @@ unsynced. 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. +- Needs `BREVO_API_KEY`/`BREVO_LIST_ID` set in the deployment environment, + plus `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every signup + silently no-ops without it) and optionally `BREVO_DOI_REDIRECT_URL`. +- **`NewsletterModal.tsx`'s left photo** stretches (`items-stretch`, + `md:aspect-auto`) to match the right column's height — the + emailError/already-subscribed messages are always rendered with a + reserved `min-h-[1.05rem]` (never conditionally mounted), so toggling + them never changes the column's height, and the photo never resizes + along with it. ## Orders & customer accounts An account is required to buy — there is no guest checkout. Registration happens inline in `/checkout`'s "1. Rechnungsadresse" card (a password -field appears there when nobody's logged in). 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` +field appears there when nobody's logged in), or standalone via +`/konto/registrieren` (`RegisterForm.tsx`, reusing the same +`/api/account/register` route) for a visitor who wants an account +independent of a purchase (e.g. to use the wishlist, see below). There's +no persistent "already a customer? log in" prompt shown to every +logged-out visitor regardless of relevance — instead, the checkout email +field's `onBlur` calls `/api/account/check-email` (`checkEmailExists()` in `customerAuth.ts`, service-secret authenticated — Customers isn't public-read) and only *then* swaps Card 1's own "Passwort (für dein neues Konto)" field out for an inline login prompt -(password field + "Einloggen" button), 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 `
` 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. +(password field + "Einloggen" button), rendered right under the same +email field the shopper just typed into rather than asking for it a +second time in a separate field — a `useEffect` scrolls it into view if +needed. `handleSubmit`'s own `emailExists` handling (see "Checkout +registration collisions" below) is the fallback for the case this check +was skipped or raced. `LoginForm.tsx` honors an optional `?redirect=` +param (used by the wishlist's login-gate, see below), landing back on +whatever page sent the visitor to log in instead of always +`/konto/bestellungen`. - **`app/lib/customerAuth.ts`** (server-only) is the single place that talks to Payload's `customers` collection — a second, fully separate @@ -1293,13 +1257,29 @@ ref isn't attached to anything yet at that exact synchronous point. `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` +- **`/konto/bestellungen`** lists a customer's own orders — a per-card CSS + Grid (`grid grid-cols-2 min-[500px]:grid-cols-4`, so every card's + Bestellnummer/Datum/Artikel/Status/Zahlungsstatus/Gesamtbetrag fields + line up at identical x-positions regardless of label width, dropping to + 2 columns below 500px), status shown as a colored `OrderStatusBadge.tsx` + plus a `PaymentStatusBadge.tsx` (Offen/Bezahlt/…, in the same + red/subtle-background style as a failed fulfillment status when unpaid), + plus an "Abmelden" link (`LogoutButton.tsx`); optional + status/paymentStatus/year filters (`OrderFilters.tsx`, 3 `CustomSelect` + dropdowns, URL-search-param-driven) when the tenant's `orderFilterEnabled` + toggle is on (see "Feature toggles" below). **`/konto/bestellungen/[orderNumber]`** + shows one order's full detail (items, address, totals, `status`, plus + companyName/VAT-ID and a VAT-exemption note when the order has them). A + "Zahlungsart ändern" button appears when an order is still unpaid + Überweisung and an active Stripe payment method exists — reuses the same + Stripe `PaymentStep`/`/checkout/verarbeitung` polling flow checkout + itself uses (via a `returnContext`/`context` prop so confirmation lands + back on the order page instead of `/bestellbestaetigung`), and sends + `sendPaymentSwitchedEmail()` (`lib/orderEmail.ts`) instead of the usual + order-confirmation mail once payment confirms — same shape as the + status-change emails, but re-attaches a freshly generated invoice PDF + (same `invoiceNumber`, never re-issued) now marked "Bereits beglichen" + instead of showing the Vorkasse notice. `status` (`received` → `processing` → `shipped` → `delivered`, plus `cancelled`/`return_requested`/`returned`) is maintained by hand in the Payload admin for the shipping states — no shipping-carrier API @@ -1346,6 +1326,63 @@ ref isn't attached to anything yet at that exact synchronous point. language as the desktop nav links' active-state indicator — since the icon alone doesn't otherwise signal session state at a glance. +### Feature toggles + +A handful of features are gated behind `CompanySettings` booleans +(Payload admin → Company Settings → **Features** tab): `wishlistEnabled`, +`searchEnabled`, `shopFilterEnabled`, `blogFilterEnabled`, +`orderFilterEnabled`. Each stays entirely invisible in the frontend — +no icon, no filter UI, no route reachable — until its toggle is switched +on; if a feature "isn't showing," check that setting first. + +### Wishlist + +Backend `wishlist-items` collection (customer + product + optional +variant, unique per combination, customer-scoped access — see the +Payload backend's own README). Frontend: `useWishlist.ts` (a fetch-based +hook with an optimistic toggle + a `window` event so every +`WishlistButton`/the Navbar badge stay in sync — not `localStorage`-backed +like the cart, since a wishlist needs a logged-in customer to mean +anything), `WishlistButton.tsx` (heart icon, redirects to login with +`?redirect=` if logged out), the Navbar's heart icon + count badge +(hidden below `sm:` — Account+Cart are the only always-visible icons on +true mobile, to avoid crowding the nav), and `/konto/merkliste` +(`MerklisteGrid.tsx`, a Client Component grid so removing an item +disappears immediately). `Product` carries a `numericId` field (the raw +Payload id) alongside its slug-based `id`, since `wishlist-items.product` +is a real numeric relationship, unlike the cart/checkout's slug-keyed +"commerce id". + +Buying a wishlisted item never auto-removes it — a customer often +wishlists something specifically to buy it again (gifts, repurchases). +Instead `/konto/merkliste` marks it: dimmed image + a "Gekauft am [date]" +badge (replacing the "Ausverkauft" badge when both would apply). Removal +stays entirely manual via the same `WishlistButton`. +`WishlistItem.purchasedAt` (`app/lib/customerAuth.ts`) is never persisted +on the backend collection — it's derived fresh on every `getWishlist()` +call by cross-referencing the customer's own orders +(`getPurchasedVariantMap()`), matched on the exact `(product, variant)` +pair, excluding `cancelled`/`returned` orders. + +### Search & filters + +**Instant search** — `/api/search` runs plain Payload +`where[...][contains]` queries (Postgres `ILIKE`) against Products + +Posts, not a dedicated search index; `SearchOverlay.tsx` (debounced +250ms, grouped results) plus a Navbar search icon (same `hidden sm:` +treatment as the wishlist icon). + +**Filters** are URL-search-param-driven everywhere (shareable/bookmarkable, +no client-side-only state): `/konto/bestellungen`'s status/paymentStatus/year +dropdowns (`OrderFilters.tsx`, `CustomSelect.tsx`); `/blog`'s category +toggle chips (`buildCategoryHref()`, plain server-rendered ``s); +`/shop`'s price min/max range (`PriceRangeFilter.tsx`). +**`CustomSelect.tsx`** (`app/components/`) is a fully custom-styled +dropdown (own trigger + `role="listbox"` panel, keyboard nav, +click-outside-to-close) used wherever a native `` 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. + Its "Land" `