Marco 028a1fc4ec Add active-product-count-driven automation
- Products gain `active`/`spotlight*`/`updatedAt` on the base Product type
  (folded in from the now-removed separate SpotlightProduct type) so shop
  grid, spotlight, and related-products can each filter `.active` from the
  same already-fetched list — cart/checkout/order-confirmation/product-
  detail pages keep resolving any product regardless of active status.
- getSpotlightProduct() now derives from getProducts() instead of its own
  Payload query: with exactly 1 active product, that one IS the spotlight
  (overriding any `spotlight` flag elsewhere); otherwise same
  most-recently-updated tie-break as before, just computed client-side.
- ProductGrid drops the already-dead SHOP_GRID_EXCLUDE_IDS list in favor of
  the same `active` filter, with an empty-state message if 0 active.
- RelatedProducts gates on >=2 active products regardless of cart contents
  or how many display slots would otherwise resolve.
- Navbar's "Shop" link becomes an anchor to the homepage spotlight section
  (id="spotlight") instead of a real /shop navigation whenever exactly 1
  product is active — passed down from the now-async root layout, which
  fetches the catalog once for this decision.
2026-07-21 20:39:22 +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, no auth — all editable content comes from the shared Payload CMS at payload.mk360.de (see 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). 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 Platformusers 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 (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%