37b710c933
Adds the testimonials row to the collections table, PAYLOAD_PREVIEW_SECRET/ NEXT_PUBLIC_PAYLOAD_URL to the env vars section, and a new Live Preview section covering /api/preview, the 3 Live-Preview-aware components, and the next/headers RSC-boundary gotcha hit while building it.
170 lines
12 KiB
Markdown
170 lines
12 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). 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`); only 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`, `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set) |
|
|
| `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`, `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 (see the infra
|
|
README's Payload CMS section for the access-control details).
|
|
|
|
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.
|
|
|
|
## 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`.
|
|
- **Checkout has no real backend.** `/checkout`'s "Jetzt kaufen" click writes
|
|
a one-time snapshot (chosen shipping/payment method, cart contents) 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. Treat this 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
|