2026-07-29 21:16:54 +00:00
2026-07-29 20:11:55 +00:00

einfach-produktiv — Frontend

Next.js frontend for 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

npm install
npm run dev

Open 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 Platformusers 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.tscomputeSubtotal()/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 19. 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 accountPAYMENT_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:

  • 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).
  • 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).
  • 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 keysEntwicklerbereich → API-Schlüssel: sk_test_... and pk_test_....

  3. Create a webhook endpointEntwicklerbereich → 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 26 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 12 business days after payment received" note.

Checkout state persistence

app/lib/checkoutDraft.tslocalStorage 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/.biciban/ 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.tsdestinationCountry() 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 insteadOrderSnapshot.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.tsisValidEmail()/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 (receivedprocessingshippeddelivered, 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.tsdescribeBundleContents(), extracted out of app/api/checkout/route.ts into its own module (app/lib/bundleContents.ts) specifically so it's importable from a test — Next.js route.ts files only allow HTTP-method (+ a few config) exports, not arbitrary named ones.

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

Deployment

  • Dockerfile: 3-stage build (depsbuilderrunner) 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.
  • ~/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
S
Description
No description provided
Readme 26 MiB
Languages
TypeScript 99.1%
CSS 0.8%