Marco 5232b14cdf Generate invoice PDFs attached to order confirmation, and send emails on order status changes
Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now
generated at checkout and attached to the confirmation email, plus
available on demand from the order-detail page. Payload-side, orders now
also email the customer on shipped/cancelled/return_requested/returned,
with Stornorechnung/Gutschrift correction PDFs attached for the latter two
so the original invoice's immutable number stays honest.
2026-07-22 09:49:56 +00:00

einfach-produktiv — Frontend

Next.js frontend for einfach-produktiv.mk360.de, Coolify-managed and deployed from this repo (git.mk360.de/Marco/einfach-produktiv). For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the shared Payload instance), see ~/dev/README.md — this file only covers what's specific to this project.

Stack

  • Next.js 16.2.9 (App Router, output: "standalone" for a small Docker image — see AGENTS.md before touching anything version-specific, this Next.js release differs from older training-data conventions)
  • React 19.2.4, Tailwind CSS 4, Motion for animation
  • No local database — all editable content (and now orders/customer accounts) lives in the shared Payload CMS at payload.mk360.de (see below). Customer auth is Payload's own (a second, separate auth: true collection there, customers — not this app's own user store), bridged via an httpOnly session cookie this app mints itself; see "Orders & customer accounts" below.

Getting started

npm install
npm run dev

Open http://localhost:3000. npm run build && npm run start reproduces the production build locally — do this before pushing, since Coolify builds with --no-cache and a failed build only surfaces there otherwise.

Environment variables: PAYLOAD_URL (defaults to https://payload.mk360.de if unset, see app/lib/payload.ts). PAYLOAD_PREVIEW_SECRET (no safe default — required for Live Preview, see below; must match the value set on the Payload backend). NEXT_PUBLIC_PAYLOAD_URL (optional, defaults to the same https://payload.mk360.de — only needed if the client-side Live Preview components should ever point somewhere else). DISCOUNT_SERVICE_SECRET (no safe default — required for discount codes to validate/redeem at all; must match the value set on the Payload backend). ORDER_SERVICE_SECRET (no safe default — required for /api/checkout to persist an order in Payload at all, and for /api/account/verify-email to look up a customer by their verification token; 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

All reads are public (access.read: () => true) except discount-codes (see its own row below); writes are admin-gated in the Payload admin UI at payload.mk360.de/admin.

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())
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)

All of the above (except media, users, tenants) are grouped in the Payload admin sidebar under Commerce (products, discount-codes, shipping-methods, shipping-settings, payment-methods, trust-badges, cart-trust-badges) or Content (posts, categories, legal-pages, werkzeuge-cards, testimonials); media/users/tenants sit under Platformusers and tenants are hidden from non-super-admins' nav entirely, and every tenant-scoped collection's own "assigned tenant" field is hidden from non-super-admins in the edit view (though not yet the list view's column — see the infra README's Payload CMS section for why that one's a harder fix).

Editorial changes (prices, copy, images, toggling a shipping/payment method on or off) all happen in the Payload admin UI — no code deploy needed. 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.

  • 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.

  • No manual input field anymore — the Rabattcode section on /cart (CartContent.tsx) only renders at all when a code is actually applied; there's no open "enter a code" box for every visitor (Nutzer-Entscheidung: less visual noise, and codes are meant to be shared as marketing links, not guessed/typed in). Instead, ?code=SAVE10 on the /cart URL 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). A code that arrives via the URL but turns out invalid/expired still shows an inline error, just without an input box to attach it to.
  • app/lib/discountServer.ts (server-only, imported exclusively by the two route handlers below — never by a "use client" component, same reasoning as Live Preview's next/headers lesson above) talks to Payload's discount-codes collection using an x-discount-service-secret header (DISCOUNT_SERVICE_SECRET), since that collection isn't public-read.
  • app/api/discount/validate/route.ts — read-only check (active / validity window / minimum order value / remaining redemptions), called when a shopper clicks "Anwenden" in the cart.
  • app/api/discount/redeem/route.ts — re-validates, then increments the collection's redemptionCount. Called exactly once, from CheckoutContent.tsx's handlePurchase(), right before the OrderSnapshot is written — a code that expired or hit its redemption cap between being applied in the cart and the actual purchase click fails the purchase with an inline error instead of silently completing.
  • app/lib/discount.ts mirrors lib/cart.ts's exact localStorage + useSyncExternalStore pattern, so the applied code survives the /cart/checkout navigation the same way the cart itself does.
  • app/lib/cartTotals.tscomputeSubtotal()/computeCartTotals(), factored out of what used to be independently-duplicated subtotal/ savings/total math in CartContent.tsx, CheckoutContent.tsx, and BestellbestaetigungContent.tsx; now also folds in the discount amount (clamped so a total can never go negative). OrderSnapshot persists the applied discountCode/discountAmount so the confirmation page shows what actually happened, not a fresh re-derivation.
  • Known, accepted limitation: the redeem route's read-then-increment isn't atomic against a true concurrent race on a capped code's very last redemption — not worth custom atomic SQL at this shop's traffic level.

Cart & checkout

  • Cart (app/lib/cart.ts) is entirely client-side, stored in localStorage under ep_cart, keyed by each product's slug. Still the source of truth while browsing — the server-side mirror (see below) only exists to carry a logged-in customer's cart across devices/browsers.
  • 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.
  • 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.

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), and orders/[orderNumber] (PATCH — cancel/return-request, see "Order cancellation & returns" 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 (receivedprocessingshippeddelivered, 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, desktop; "Anmelden"/"Mein Konto" text link, mobile drawer) 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. 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.
  • 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

Both transactional emails (order confirmation, password reset) 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()), 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 (password-reset's actual send uses Payload's own simple inline template instead — see that repo's README for why — so its Live Preview approximates rather than pixel-matches). 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 both rows — deliberately on-brand and a little playful ("Geschafft!" / "Kein Drama.", not generic transactional-email boilerplate), matching this site's voice elsewhere (see e.g. the testimonial copy). 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.

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().

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 now rejects a customer-authenticated update unless the only changed field is status, via an allowed transition. See the Payload README's own writeup for the full detail.

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.

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).

Deployment

  • Dockerfile: 3-stage build (depsbuilderrunner) with BuildKit cache mounts, producing a ~100 MB standalone image. This is the reference Dockerfile copied when scaffolding new projects on this VPS (see the infra repo's new-project workflow).
  • Deploys via git push → Gitea webhook → Caddy /deploy/einfach-produktiv bridge → Coolify API → rebuild + restart. Full mechanics in ~/dev/README.md.
  • ~/dev/einfach-produktiv/mockups/ — Figma-stage mockup PNGs
  • ~/dev/einfach-produktiv/styleguide.md — design tokens (colors, type, spacing)
  • ~/dev/einfach-produktiv/einfachproduktiv_figma_prompt_guide_v3.md — Figma rebuild prompt guide
S
Description
No description provided
Readme 26 MiB
Languages
TypeScript 99.1%
CSS 0.8%