Files
einfach-produktiv/README.md
T
Marco 516945fc8c Document discount codes, active-column, and RelatedProducts changes
New Discount codes section (validate/redeem routes, discountServer.ts,
cartTotals.ts, localStorage persistence), DISCOUNT_SERVICE_SECRET env var,
discount-codes collection row (first non-public-read collection), and a
note on RelatedProducts no longer padding its grid with already-in-cart
products.
2026-07-21 21:37:37 +00:00

219 lines
15 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
- No local database, no auth — all editable content comes from the shared
Payload CMS at `payload.mk360.de` (see 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). 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 |
| `/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`** — 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.
- **`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 — demo status
- **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in
`localStorage` under `ep_cart`, keyed by each product's `slug`.
- **`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 has no real backend for the *order* itself.** `/checkout`'s
"Jetzt kaufen" click writes a one-time snapshot (chosen shipping/payment
method, cart contents, applied discount) to `sessionStorage`
(`app/lib/order.ts`), which `/bestellbestaetigung` reads once and
displays — that snapshot *is* the order record. There is no payment
processing, no persisted order in Payload or anywhere else, and no
confirmation email yet — discount-code validation/redemption is the one
part of this flow with real server-side enforcement today (see above).
Treat the rest as a frontend/demo checkout flow, not a functioning store.
## 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