# einfach-produktiv — Frontend Next.js frontend for [einfach-produktiv.mk360.de](https://einfach-produktiv.mk360.de), Coolify-managed and deployed from this repo (`git.mk360.de/Marco/einfach-produktiv`). For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the shared Payload instance), see `~/dev/README.md` — this file only covers what's specific to this project. ## Stack - **Next.js 16.2.9** (App Router, `output: "standalone"` for a small Docker image — see `AGENTS.md` before touching anything version-specific, this Next.js release differs from older training-data conventions) - **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation - **`@react-pdf/renderer`** for invoice PDF generation (see "Invoice PDFs" below) — no headless-browser dependency - No local database — all editable content (and now orders/customer accounts) lives in the shared Payload CMS at `payload.mk360.de` (see below). Customer auth is Payload's own (a second, separate `auth: true` collection there, `customers` — not this app's own user store), bridged via an httpOnly session cookie this app mints itself; see "Orders & customer accounts" below. ## Shared modules `@einfach-produktiv/invoicing` (a separate repo, consumed as a git dependency — see "Invoice PDFs" below) is where framework-agnostic logic shared with the Payload backend lives, so it's written once instead of maintained as two independently-drifting copies. Beyond invoice/e-invoice PDF generation and tax-breakdown math, it also holds VIES VAT-ID checking, VAT-ID format validation, PLZ digit-count validation, and shipping-carrier tracking-label logic — all previously hand-duplicated `app/lib/*.ts` files in this repo, now imported from the shared package instead. See that package's own README for what's in it and why each piece was unified. ## Getting started ```bash npm install npm run dev ``` Open [http://localhost:3000](http://localhost:3000). `npm run build && npm run start` reproduces the production build locally — do this before pushing, since Coolify builds with `--no-cache` and a failed build only surfaces there otherwise. **Environment variables:** `PAYLOAD_URL` (defaults to `https://payload.mk360.de` if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe default — required for Live Preview, see below; must match the value set on the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the same `https://payload.mk360.de` — only needed if the client-side Live Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET` (no safe default — required for discount codes to validate/redeem at all; must match the value set on the Payload backend). `ORDER_SERVICE_SECRET` (no safe default — required for `/api/checkout` to persist an order in Payload at all, for `/api/account/verify-email` to look up a customer by their verification token, and for `getCompanySettings()` to read the `company-settings` collection (seller data for invoice PDFs); must match the value set on the Payload backend — also used there for the same header). `SMTP_USER`/`SMTP_PASSWORD` (no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure alerts and resend-verification emails; **does not** need to match anything on the Payload side — this app's SMTP connection is deliberately independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing (Stripe)" below. `BREVO_API_KEY`, `BREVO_LIST_ID`, `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — required for newsletter signups to trigger a confirmation email at all), `BREVO_DOI_REDIRECT_URL` (optional, defaults to `/newsletter-confirmed`) — see "Newsletter signup" below. Set in Coolify's app settings for production, not in a committed `.env`. ## Pages | Route | Purpose | |---|---| | `/` | Home — hero, product spotlight, tools grid, trust row | | `/shop` | Product grid (all active products for this tenant) | | `/blog`, `/blog/[slug]` | Blog overview + post detail | | `/cart` | Cart (localStorage-backed, see below) | | `/checkout` | Shipping + payment method selection, order summary | | `/bestellbestaetigung` | Order confirmation — reads the one-time snapshot `/checkout` wrote | | `/konto/login`, `/konto/registrieren` | Customer login / standalone registration | | `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) | | `/konto/profil` | Profile/address editing + password change | | `/konto/merkliste` | Wishlist (when `wishlistEnabled`, see "Feature toggles" below) | | `/challenge` | "Mini-Challenge" tool | | `/todo-cards` | "Todo-Karten" tool | | `/newsletter` | Newsletter signup | | `/versand` | Shipping policy (timeframes, costs) | | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) | ### Responsive design The site uses a fluid, `clamp()`-based responsive system (`app/lib/fluid.ts`, tokens hand-authored into `app/globals.css`'s `@theme`/`:root` blocks) that scales continuously between a 640px floor and a 1440px ceiling — most components need no structural breakpoint at all. Where a genuine structural reflow is needed (grid-to-stack, flex-direction switches), the site uses a single `sm:` (640px) breakpoint, with a documented handful of exceptions still gated on `lg:` (1024px) for real fixed-width content: the Cart/Checkout two-column split, the legal-page table-of-contents sidebar, the newsletter/todo-cards hero and benefits sections, `Footer.tsx`'s legal-links row, and `TrustRow.tsx`. Below `lg:` (1024px), the Navbar's hamburger opens a fullscreen nav panel (circular reveal animation, a sibling of `
` rather than a child so the header's own `backdrop-blur` doesn't break the panel's fixed-to-viewport positioning) instead of an inline dropdown; the wishlist/search icons are hidden below `sm:` (640px) so Account + Cart stay the only always-visible icons on narrow phones (see "Feature toggles" below). `Navbar.tsx` itself keeps its own separate `md:`/`lg:` 3-tier scheme for its nav links — horizontal nav-link overflow is a different failure mode than the vertical grid/flex reflows the rest of the site handles with `sm:`. ### SEO & structured data Every page can carry real per-page metadata — `/blog/[slug]`'s `generateMetadata()` reads a post's own `seoTitle`/`seoDescription`/`seoImage` (see the `posts` collection below); `app/layout.tsx`'s `generateMetadata()` reads `getSeoSettings()` (ISR-cached, backed by `company-settings`) for the site-wide fallback. JSON-LD structured data (`app/lib/structuredData.ts`, no headless external validation step, just direct grepping of the rendered `application/ld+json` script) is emitted for the pages Google gives rich results for: an `Organization` schema site-wide in the root layout (from `getCompanySettings()`, excluding sensitive fields like `iban`/`bic`, with a stable `@id` that other schemas link back to rather than repeating the full object), a `Product` schema on `/todo-cards` (the one page with its own dedicated single-product URL — deliberately not on `/shop`'s grid, since most products there have no individual detail page to point a `Product`'s `url` at), and a `BlogPosting` schema on every `/blog/[slug]` page. ## Content backend: Payload CMS This app is one **tenant** in a shared, multi-tenant Payload instance also 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), `sku` (optional, even without variants — variants carry their own; snapshotted onto each order item at checkout and shown as "Art.-Nr." on the invoice PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`) | | `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below | | `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `categories` (`hasMany` relation to `categories` — a post can have several, joined with `", "` wherever rendered), `excerpt`, `thumbnail`, `content` (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via `app/components/RichText.tsx`'s `@payloadcms/richtext-lexical/react` renderer with custom `JSXConverters`), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`), `seoTitle`/`seoDescription`/`seoImage` (each falls back to `title`/`excerpt`/`thumbnail` when empty, consumed by `/blog/[slug]`'s `generateMetadata()`) | | `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) | | `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `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 10 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`payment-method-switched`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button | | `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below | All of the above except `company-settings`, `media`, `users`, `tenants` are grouped in the Payload admin sidebar under **Commerce** (`products`, `discount-codes`, `orders`, `customers`, `number-ranges`, `email-templates`, `shipping-methods`, `shipping-settings`, `payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content** (`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`). `company-settings` sits in its own **Company** group; `media`/`users`/`tenants` sit under **Platform** — `users` and `tenants` are hidden from non-super-admins' nav entirely, and every tenant-scoped collection's own "assigned tenant" field is hidden from non-super-admins in the edit view (though not yet the list view's column — see the infra README's Payload CMS section for why that one's a harder fix). **What an admin can actually configure without a code deploy, at a glance:** product/shipping/payment catalog data and `active` toggles (incl. per-product tax-rate overrides and defining a product as a bundle), discount codes, all page content (blog/legal/testimonials/trust badges), delivery-time disclosure (`shipping-settings`), order/customer/invoice numbering schemes (`number-ranges`), all 10 email wordings (`email-templates`, with Live Preview), and invoice seller data, bank details, and VAT rate (`company-settings` — also what every email's legal footer is sourced from). What still requires a code change: adding a new *field* to any collection (needs a migration), payment processing itself (not built), and anything structural in `orders`/`customers` beyond `status`/`returnReason` and the profile fields already exposed on `/konto/profil`. Adding a *new field* to any collection above requires editing the collection file in `docker/payload/src/collections/` and a migration, which does need a deploy of the Payload service. ### Live Preview `posts`, `legal-pages`, and `testimonials` support Payload's Live Preview — opening a document in the Payload admin shows this app's real rendered page in an iframe, updating as you type, no save required. `email-templates` also has Live Preview, but against a synthetic page + sample data rather than one of these three's own real page — different enough to cover separately, see "Email templates & Live Preview" further below. - **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET` (matching value required on the Payload side too, or this route 401s) and a `path`, enables Next.js Draft Mode, then redirects into the real page. This is the link Payload's `livePreview.url` resolvers point at (see `docker/payload/src/lib/previewUrl.ts` in the infra repo) — never the page directly. - Each supported page checks `draftMode().isEnabled` and renders a `"use client"` Live-Preview-aware component instead of the plain static one only when it's `true` — ordinary visitors are never in Draft Mode, so they always get the plain version with zero extra client JS: - `app/components/LiveRichText.tsx` — the 4 legal pages' body content (their headings/sidebars are hardcoded per page, not CMS-sourced, so `content` is the only field worth live-previewing there) - `app/blog/[slug]/components/LivePostContent.tsx` — title/excerpt/ thumbnail/body of a blog post (the author bio card, "Weiterlesen" card, and Footer stay static — they either aren't post-specific or are about a *different* post, not the one open in the admin) - `app/components/LiveTestimonialsGrid.tsx` — the one testimonial currently open in the admin, merged by `id` into the rest of that page's already-fetched grid (Live Preview is inherently single-document, but this page renders several at once) - All three use `@payloadcms/live-preview-react`'s `useLivePreview` hook and reuse the same mapping functions (`mapPayloadPost`, `mapPayloadTestimonial`, exported from `app/lib/payload.ts`) the plain server-side fetchers use, so the two code paths can't silently drift apart. - `app/lib/payload.ts` deliberately never imports `next/headers` itself — callers (the Server Component pages) call `draftMode()` themselves and pass the result in as a plain `{ draft: boolean }` option. Importing `next/headers` anywhere in that module breaks the production build the moment a `"use client"` component (which also needs this file's mapping functions/types) tries to bundle it — a real RSC-boundary regression hit once already when adding this feature. ## Discount codes Applied in `/cart` only (`/checkout` displays the already-applied result, no second input) — real server-side validation, not just a client-side check against Payload's public API, unlike most content on this site. - **Manual input field on `/cart`** (`CartContent.tsx`) — a text field + "Anwenden" button, shown whenever no code is currently applied *and* Payload actually has at least one active code right now (`hasActiveDiscountCode()` in `discountServer.ts`, `where[active][equals]=true`, ISR-cached 60s — no point offering an open field that could never validate against anything). Once applied, the field is replaced by a read-only result + "Entfernen" link, shown regardless of that check (an already-applied code, e.g. from an older session, still needs somewhere to display even if no *other* code happens to be active right now). `?code=SAVE10` on the `/cart` URL still auto-applies once on arrival (a `useEffect` reading `useSearchParams()` — requires `/cart`'s `page.tsx` to wrap `CartContent` in ``, a Next.js requirement for any `useSearchParams()` consumer) regardless of `hasActiveDiscountCode()` too, so a marketing link still works without the shopper typing anything; if that auto-apply fails, the error shows even without the manual field present. - **`app/lib/discountServer.ts`** (server-only, imported exclusively by the two route handlers below — never by a `"use client"` component, same reasoning as Live Preview's `next/headers` lesson above) talks to Payload's `discount-codes` collection using an `x-discount-service-secret` header (`DISCOUNT_SERVICE_SECRET`), since that collection isn't public-read. - **`app/api/discount/validate/route.ts`** — read-only check (active / validity window / minimum order value / remaining redemptions), called when a shopper clicks "Anwenden" in the cart. - **`app/api/discount/redeem/route.ts`** — re-validates, then increments the collection's `redemptionCount`. Called exactly once, from `CheckoutContent.tsx`'s `handlePurchase()`, right before the `OrderSnapshot` is written — a code that expired or hit its redemption cap between being applied in the cart and the actual purchase click fails the purchase with an inline error instead of silently completing. - **`app/lib/discount.ts`** mirrors `lib/cart.ts`'s exact `localStorage` + `useSyncExternalStore` pattern, so the applied code survives the `/cart` → `/checkout` navigation the same way the cart itself does. - **`app/lib/cartTotals.ts`** — `computeSubtotal()`/`computeCartTotals()`, factored out of what used to be independently-duplicated subtotal/ savings/total math in `CartContent.tsx`, `CheckoutContent.tsx`, and `BestellbestaetigungContent.tsx`; now also folds in the discount amount (clamped so a total can never go negative). `OrderSnapshot` persists the applied `discountCode`/`discountAmount` so the confirmation page shows what actually happened, not a fresh re-derivation. - Known, accepted limitation: the redeem route's read-then-increment isn't atomic against a true concurrent race on a capped code's very last redemption — not worth custom atomic SQL at this shop's traffic level. ## Cart & checkout - **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in `localStorage` under `ep_cart`, keyed by each product's `slug`. Still the source of truth while browsing — the server-side mirror (see below) only exists to carry a logged-in customer's cart across devices/browsers. - **Add-to-cart is capped at actual remaining stock.** `Product`/its variants carry a real `maxQty` (`app/lib/payload.ts`'s `mapPayloadProduct()` — `null` when unlimited, i.e. backorder allowed or inventory untracked; a deliberate, narrow exception to that function's own "the public API has no reason to leak exact stock counts" comment, since the add-to-cart controls genuinely need it). `AddToCartButton`/ `AddToCartInlineButton` disable (and show "Maximale Menge im Warenkorb") once the cart already holds that many; `/cart`'s quantity ``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 ``'s un-stylable options popup would look off-brand — also used for checkout's two country pickers. ### Mobile navigation Below `lg` (1024px), the hamburger opens a **fullscreen** panel (`Navbar.tsx`, `motion.div` from the `motion/react` package already used elsewhere in this app for `NewsletterModal`/`VersandModal`) — not an in-flow accordion pushed under the header like before. A circular `clip-path` reveal (`circle(0vmax at 100% 0%)` → `circle(150vmax at 100% 0%)`, `vmax` rather than `%` so full coverage holds regardless of aspect ratio) expands from the hamburger's own top-right corner, sweeping toward the opposite corner last. Nav links fade/rise in with a per-item stagger once the reveal has visibly opened up. The panel is a **sibling** of `
`, 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" `