43944d8cc8
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1053 lines
68 KiB
Markdown
1053 lines
68 KiB
Markdown
# einfach-produktiv — Frontend
|
||
|
||
Next.js frontend for [einfach-produktiv.mk360.de](https://einfach-produktiv.mk360.de),
|
||
Coolify-managed and deployed from this repo (`git.mk360.de/Marco/einfach-produktiv`).
|
||
For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the
|
||
shared Payload instance), see `~/dev/README.md` — this file only covers what's
|
||
specific to this project.
|
||
|
||
## Stack
|
||
|
||
- **Next.js 16.2.9** (App Router, `output: "standalone"` for a small Docker
|
||
image — see `AGENTS.md` before touching anything version-specific, this
|
||
Next.js release differs from older training-data conventions)
|
||
- **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation
|
||
- **`@react-pdf/renderer`** for invoice PDF generation (see "Invoice
|
||
PDFs" below) — no headless-browser dependency
|
||
- No local database — all editable content (and now orders/customer
|
||
accounts) lives in the shared Payload CMS at `payload.mk360.de` (see
|
||
below). Customer auth is Payload's own (a second, separate `auth: true`
|
||
collection there, `customers` — not this app's own user store), bridged
|
||
via an httpOnly session cookie this app mints itself; see "Orders &
|
||
customer accounts" below.
|
||
|
||
## Getting started
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev
|
||
```
|
||
|
||
Open [http://localhost:3000](http://localhost:3000). `npm run build && npm run
|
||
start` reproduces the production build locally — do this before pushing,
|
||
since Coolify builds with `--no-cache` and a failed build only surfaces there
|
||
otherwise.
|
||
|
||
**Environment variables:** `PAYLOAD_URL` (defaults to `https://payload.mk360.de`
|
||
if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe
|
||
default — required for Live Preview, see below; must match the value set on
|
||
the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the
|
||
same `https://payload.mk360.de` — only needed if the client-side Live
|
||
Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
|
||
(no safe default — required for discount codes to validate/redeem at all;
|
||
must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
|
||
(no safe default — required for `/api/checkout` to persist an order in
|
||
Payload at all, for `/api/account/verify-email` to look up a customer
|
||
by their verification token, and for `getCompanySettings()` to read the
|
||
`company-settings` collection (seller data for invoice PDFs); must match
|
||
the value set on the Payload backend — also used there for the same
|
||
header). `SMTP_USER`/`SMTP_PASSWORD`
|
||
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
||
alerts and resend-verification emails; **does not** need to match anything
|
||
on the Payload side — this app's SMTP connection is deliberately
|
||
independent, see the "Monitoring & alerting" section). 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 numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`. **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 6 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`), `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), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
|
||
|
||
All of the above except `company-settings`, `media`, `users`, `tenants`
|
||
are grouped in the Payload admin sidebar under **Commerce** (`products`,
|
||
`discount-codes`, `orders`, `customers`, `number-ranges`,
|
||
`email-templates`, `shipping-methods`, `shipping-settings`,
|
||
`payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content**
|
||
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`).
|
||
`company-settings` sits in its own **Company** group; `media`/`users`/`tenants`
|
||
sit under **Platform** — `users` and `tenants` are hidden from
|
||
non-super-admins' nav entirely, and every tenant-scoped collection's own
|
||
"assigned tenant" field is hidden from non-super-admins in the edit view
|
||
(though not yet the list view's column — see the infra README's Payload
|
||
CMS section for why that one's a harder fix).
|
||
|
||
**What an admin can actually configure without a code deploy, at a
|
||
glance:** product/shipping/payment catalog data and `active` toggles
|
||
(incl. per-product tax-rate overrides and defining a product as a bundle),
|
||
discount codes, all page content (blog/legal/testimonials/trust badges),
|
||
delivery-time disclosure (`shipping-settings`), order/customer/invoice
|
||
numbering schemes (`number-ranges`), all 6 email wordings
|
||
(`email-templates`, with Live Preview), and invoice seller data,
|
||
bank details, and VAT rate (`company-settings` — also what every email's
|
||
legal footer is sourced from). What still requires a code change:
|
||
adding a new *field* to any collection (needs a migration), payment
|
||
processing itself (not built), and anything structural in
|
||
`orders`/`customers` beyond `status`/`returnReason` and the profile fields
|
||
already exposed on `/konto/profil`.
|
||
|
||
Adding a *new field* to any collection above requires editing the
|
||
collection file in `docker/payload/src/collections/` and a migration,
|
||
which does need a deploy of the Payload service.
|
||
|
||
### Live Preview
|
||
|
||
`posts`, `legal-pages`, and `testimonials` support Payload's Live Preview —
|
||
opening a document in the Payload admin shows this app's real rendered page
|
||
in an iframe, updating as you type, no save required. `email-templates`
|
||
also has Live Preview, but against a synthetic page + sample data rather
|
||
than one of these three's own real page — different enough to cover
|
||
separately, see "Email templates & Live Preview" further below.
|
||
|
||
- **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET`
|
||
(matching value required on the Payload side too, or this route 401s) and
|
||
a `path`, enables Next.js Draft Mode, then redirects into the real page.
|
||
This is the link Payload's `livePreview.url` resolvers point at (see
|
||
`docker/payload/src/lib/previewUrl.ts` in the infra repo) — never the page
|
||
directly.
|
||
- Each supported page checks `draftMode().isEnabled` and renders a
|
||
`"use client"` Live-Preview-aware component instead of the plain static
|
||
one only when it's `true` — ordinary visitors are never in Draft Mode, so
|
||
they always get the plain version with zero extra client JS:
|
||
- `app/components/LiveRichText.tsx` — the 4 legal pages' body content
|
||
(their headings/sidebars are hardcoded per page, not CMS-sourced, so
|
||
`content` is the only field worth live-previewing there)
|
||
- `app/blog/[slug]/components/LivePostContent.tsx` — title/excerpt/
|
||
thumbnail/body of a blog post (the author bio card, "Weiterlesen" card,
|
||
and Footer stay static — they either aren't post-specific or are about
|
||
a *different* post, not the one open in the admin)
|
||
- `app/components/LiveTestimonialsGrid.tsx` — the one testimonial
|
||
currently open in the admin, merged by `id` into the rest of that
|
||
page's already-fetched grid (Live Preview is inherently single-document,
|
||
but this page renders several at once)
|
||
- All three use `@payloadcms/live-preview-react`'s `useLivePreview` hook and
|
||
reuse the same mapping functions (`mapPayloadPost`, `mapPayloadTestimonial`,
|
||
exported from `app/lib/payload.ts`) the plain server-side fetchers use, so
|
||
the two code paths can't silently drift apart.
|
||
- `app/lib/payload.ts` deliberately never imports `next/headers` itself —
|
||
callers (the Server Component pages) call `draftMode()` themselves and
|
||
pass the result in as a plain `{ draft: boolean }` option. Importing
|
||
`next/headers` anywhere in that module breaks the production build the
|
||
moment a `"use client"` component (which also needs this file's mapping
|
||
functions/types) tries to bundle it — a real RSC-boundary regression hit
|
||
once already when adding this feature.
|
||
|
||
## Discount codes
|
||
|
||
Applied in `/cart` only (`/checkout` displays the already-applied result,
|
||
no second input) — real server-side validation, not just a client-side
|
||
check against Payload's public API, unlike most content on this site.
|
||
|
||
- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
|
||
"Anwenden" button, shown whenever no code is currently applied; once
|
||
applied, the field is replaced by a read-only result + "Entfernen" link
|
||
(reverted an earlier no-manual-input decision). `?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), so a marketing link still works without the shopper typing
|
||
anything. A code that arrives via the URL but turns out invalid/expired
|
||
shows the same inline error the manual field uses.
|
||
- **`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.
|
||
- **`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`. `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()` — 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.
|
||
- Still not built: real payment processing — the checkout button is
|
||
labelled "zahlungspflichtig" but nothing actually captures a payment
|
||
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
||
|
||
### 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 — `app/lib/taxBreakdown.ts`'s
|
||
`computeTaxBreakdown()` (extracted out of what used to be independently
|
||
duplicated `groupByTaxRate()` logic in `invoicePdf.tsx`/
|
||
`correctionInvoicePdf.tsx`, now shared by both the PDFs and this display)
|
||
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).
|
||
|
||
### Checkout state persistence
|
||
|
||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||
plain read/write functions (not `useSyncExternalStore` like `cart.ts`/
|
||
`discount.ts`: `CheckoutContent` is this draft's only reader, no
|
||
cross-component subscription to keep in sync). Every address-card field
|
||
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
|
||
shipping-address-override fields below, newsletter opt-in) plus the
|
||
selected shipping/payment method is now a controlled input backed by this
|
||
draft, restored on mount (a `useEffect`-deferred read, same SSR/hydration-
|
||
mismatch avoidance as `BestellbestaetigungContent`'s own sessionStorage
|
||
read) and cleared on a completed purchase. `password` is deliberately
|
||
excluded — stays a plain uncontrolled, unpersisted input.
|
||
|
||
### Optional deviating shipping address
|
||
|
||
A checkbox in "1. Rechnungsadresse" ("Abweichende Lieferadresse
|
||
verwenden") reveals a second address section (own name + delivery method +
|
||
street/Packstation/PLZ/Ort/Land) — 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.
|
||
|
||
### 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 the
|
||
discount badge, never both) 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" pill (a new `--color-warning` token in `globals.css`, distinct
|
||
from the brand-colored discount badge so the two never read as the same
|
||
thing) on `ProductGrid.tsx`/`ProductSpotlight.tsx`, a `"(nur noch wenige)"`
|
||
variant-select suffix, and a text hint under `AddToCartButton`/
|
||
`AddToCartInlineButton` — same component/prop shape as `outOfStock`
|
||
throughout.
|
||
|
||
**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`.
|
||
|
||
- **`app/lib/invoicePdf.tsx`** — a `@react-pdf/renderer` `Document`
|
||
(`InvoiceDocument`), 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 with alternating row shading, 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.
|
||
- **"Bereits beglichen" badge**: shown next to the meta boxes 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 `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).
|
||
- **Bank details**: `company-settings.bankDetails`, when set, prints in
|
||
the footer as "Bankverbindung (für Überweisung): …" — for the case a
|
||
customer paid (or still needs to pay) by bank transfer and needs the
|
||
account details to do so. Currently seeded with a placeholder IBAN/BIC,
|
||
same caveat as the rest of `company-settings`' seller data below.
|
||
- **Per-tax-rate summary**: line items are grouped by their own
|
||
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
|
||
rates" section) via the shared `app/lib/taxBreakdown.ts` (see "VAT
|
||
display" above) and the summary prints one plain "Netto" / "zzgl. X%
|
||
MwSt." pair per distinct rate actually present in that order — no `%`
|
||
after "Netto" itself anymore, since the rate is already stated on the
|
||
"zzgl." line directly below it. A plain single pair in the common case
|
||
(one rate for the whole order), a real multi-rate breakdown the moment a
|
||
product with a different rate is involved. The order-level discount/
|
||
shipping are distributed proportionally across each rate group before
|
||
computing net/tax, so the grouped totals still reconcile exactly to
|
||
`order.total`.
|
||
- **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.
|
||
- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` /
|
||
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
|
||
entrypoints every caller below goes through. `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. `app/lib/correctionInvoicePdf.tsx` is a **frontend
|
||
port** of that same renderer (visually identical, ported not shared —
|
||
two separate deployments, same relationship as `emailShell.ts`) used
|
||
purely so `/konto/bestellungen/[orderNumber]` can offer a "Stornorechnung/
|
||
Gutschrift herunterladen" download button
|
||
(`app/api/account/orders/[orderNumber]/correction-invoice/route.ts`)
|
||
without storing the PDF as a file anywhere: `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. Deliberately not persisted to
|
||
disk/S3/Media — 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 gained 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).
|
||
- **`company-settings`** (Payload collection, structured seller data —
|
||
name/address/`vatId`/`taxRatePercent`/`bankDetails`) 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.
|
||
|
||
### 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`/
|
||
`bankDetails`, 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 `app/lib/invoicePdf.tsx`
|
||
specifically for this — everywhere else only the async
|
||
`renderInvoicePdf()` buffer-generator is used) against a fixed
|
||
`SAMPLE_INVOICE_ORDER` — 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.
|
||
|
||
## 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 password field
|
||
out for an inline login form, gender-neutral copy, pre-filled with the
|
||
email just typed. `handleSubmit`'s own `emailExists` handling (see
|
||
"Checkout registration collisions" below) is the fallback for the case
|
||
this check was skipped or raced.
|
||
|
||
- **`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`). `status`
|
||
(`received` → `processing` → `shipped` → `delivered`, plus
|
||
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
|
||
Payload admin for the shipping states — no shipping-carrier API
|
||
integration.
|
||
- **`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
|
||
straight to the login toggle with that email pre-filled and
|
||
scroll-into-view, instead of leaving the customer stuck with an error
|
||
and no obvious next step.
|
||
- **`/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.
|
||
- **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 6 transactional emails (order confirmation, password reset, and the 4
|
||
status-change types below) read their subject/heading/body/footer wording
|
||
from Payload's `email-templates` collection — editable in the admin
|
||
without a deploy, with a Live Preview button using the exact same
|
||
mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
|
||
`@payloadcms/live-preview-react`, already a dependency here for
|
||
`LivePostContent.tsx`).
|
||
|
||
- **`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.
|
||
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
|
||
defaults for all 6 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.
|
||
|
||
## 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__/invoicePdf.test.ts`** — `isPaidImmediately()` and
|
||
the original invoice's per-rate `groupByTaxRate()` (both exported via an
|
||
`__testables` object specifically for this, same pattern the Payload
|
||
backend uses for its own correction-invoice tests).
|
||
- **`app/lib/__tests__/bundleContents.test.ts`** — `describeBundleContents()`,
|
||
extracted out of `app/api/checkout/route.ts` into its own module
|
||
(`app/lib/bundleContents.ts`) specifically so it's importable from a
|
||
test — Next.js `route.ts` files only allow HTTP-method (+ a few config)
|
||
exports, not arbitrary named ones.
|
||
|
||
The Gutschrift/Stornorechnung money math itself (`resolveLineItems()`,
|
||
`groupByTaxRate()` in `correctionInvoicePdf.tsx`) and the Payload-side
|
||
`orders.ts` field-lock security logic are tested in the **Payload
|
||
backend's** own `test:unit` instead — see that repo's README's own
|
||
"Tests" section — since that's where those functions actually live (this
|
||
repo's `correctionInvoicePdf.tsx` is only a port for the download button,
|
||
not the source of truth).
|
||
|
||
## Deployment
|
||
|
||
- **Dockerfile**: 3-stage build (`deps` → `builder` → `runner`) with
|
||
BuildKit cache mounts, producing a ~100 MB standalone image. This is the
|
||
reference `Dockerfile` copied when scaffolding new projects on this VPS
|
||
(see the infra repo's new-project workflow).
|
||
- Deploys via `git push` → Gitea webhook → Caddy `/deploy/einfach-produktiv`
|
||
bridge → Coolify API → rebuild + restart. Full mechanics in
|
||
`~/dev/README.md`.
|
||
|
||
## Related design source
|
||
|
||
- `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs
|
||
- `~/dev/einfach-produktiv/styleguide.md` — design tokens (colors, type, spacing)
|
||
- `~/dev/einfach-produktiv/einfachproduktiv_figma_prompt_guide_v3.md` — Figma rebuild prompt guide
|