Checkout now persists orders server-side (Payload orders collection, re-priced from live product data, discount codes redeemed exactly once) instead of writing a client-only sessionStorage snapshot. Buying requires an account (registration inline in checkout, no separate step) — accounts get order history with delivery status, profile/address editing, password change, and a cart that syncs across devices while logged in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
19 KiB
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 — seeAGENTS.mdbefore 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, separateauth: truecollection 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; must match the value set on the Payload backend). Set in
Coolify's app settings for production, not in a committed .env — this app
has no other secrets.
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
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).
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— validatesPAYLOAD_PREVIEW_SECRET(matching value required on the Payload side too, or this route 401s) and apath, enables Next.js Draft Mode, then redirects into the real page. This is the link Payload'slivePreview.urlresolvers point at (seedocker/payload/src/lib/previewUrl.tsin the infra repo) — never the page directly.- Each supported page checks
draftMode().isEnabledand renders a"use client"Live-Preview-aware component instead of the plain static one only when it'strue— 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, socontentis 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 byidinto 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'suseLivePreviewhook and reuse the same mapping functions (mapPayloadPost,mapPayloadTestimonial, exported fromapp/lib/payload.ts) the plain server-side fetchers use, so the two code paths can't silently drift apart. app/lib/payload.tsdeliberately never importsnext/headersitself — callers (the Server Component pages) calldraftMode()themselves and pass the result in as a plain{ draft: boolean }option. Importingnext/headersanywhere 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.
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'snext/headerslesson above) talks to Payload'sdiscount-codescollection using anx-discount-service-secretheader (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'sredemptionCount. Called exactly once, fromCheckoutContent.tsx'shandlePurchase(), right before theOrderSnapshotis 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.tsmirrorslib/cart.ts's exactlocalStorage+useSyncExternalStorepattern, so the applied code survives the/cart→/checkoutnavigation 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 inCartContent.tsx,CheckoutContent.tsx, andBestellbestaetigungContent.tsx; now also folds in the discount amount (clamped so a total can never go negative).OrderSnapshotpersists the applieddiscountCode/discountAmountso 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 inlocalStorageunderep_cart, keyed by each product'sslug. 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.tsxonly 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 throughPOST /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'sorderscollection viaapp/lib/orderServer.ts.app/lib/order.ts'sOrderSnapshotis still written tosessionStoragefor/bestellbestaetigungto read once, but itsorderNumber/orderDateIsonow come back from that Payload create call, not generated client-side.- Still not built: real payment processing (the checkout button is
labelled "zahlungspflichtig" but nothing captures a payment) and a
transactional confirmation email — see
project_backend_checkout_planin the assistant's own memory for what's deliberately deferred to a later stage.
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); returning customers can
instead expand a small "Schon Kundin? Einloggen" toggle in the same place
without leaving the page.
app/lib/customerAuth.ts(server-only) is the single place that talks to Payload'scustomerscollection — a second, fully separateauth: truecollection 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.devspayload.mk360.de) and instead mints its own httpOnlyep_customer_tokencookie holding that JWT, forwarded as anAuthorization: JWT <token>header on every subsequent Payload call. No token refresh in this stage — Payload's ~2h default JWT lifetime means a session just expires and the customer logs in again.app/api/account/*— thin route handlers aroundcustomerAuth.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)./konto/bestellungenlists a customer's own orders;/konto/bestellungen/[orderNumber]shows one order's full detail (items, address, totals,status).status(received→processing→shipped→delivered) is maintained by hand in the Payload admin — no shipping-carrier API integration./konto/profiledits 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) and changes the password.- Cart sync:
app/components/CartSync.tsx(mounted once inapp/layout.tsx) watches the local cart viauseCart()and debounce-POSTs it to/api/account/carton every change; the route 401s (silently, by design) when nobody's logged in. On login (LoginForm.tsx, andCheckoutContent.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. - Only a single default address per account, single-currency, no order cancellation/return flow, no email verification, no password-reset (self-service — a customer who forgets their password currently has no recovery path). All known, deliberately out of scope for now.
Deployment
- Dockerfile: 3-stage build (
deps→builder→runner) with BuildKit cache mounts, producing a ~100 MB standalone image. This is the referenceDockerfilecopied when scaffolding new projects on this VPS (see the infra repo's new-project workflow). - Deploys via
git push→ Gitea webhook → Caddy/deploy/einfach-produktivbridge → 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