Document ProductCard consolidation, swipe gallery, dynamic legal-page sourcing, shipping-methods, NotifyMeForm redesign

README had fallen behind several recent changes: the shared ProductCard
component (was still describing three independently-duplicated grids),
ProductGallery's touch-swipe support, legal-pages' contentPart2 field
and company-settings-sourced name/address/email, shipping-methods as
the real cost/threshold source (lib/shipping.ts's old hardcoded
constants were deleted), and NotifyMeForm's single-row redesign.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-02 08:07:51 +00:00
parent 340dcd2c94
commit e7ad9cd88d
+53 -31
View File
@@ -70,7 +70,12 @@ independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`,
newsletter signups to trigger a confirmation email at all),
`BREVO_DOI_REDIRECT_URL` (optional, defaults to `/newsletter-confirmed`) — see
"Newsletter signup" below. Set in Coolify's app settings for production,
not in a committed `.env`.
not in a committed `.env`. For the full cross-stack picture (backend +
frontend + n8n + provisioning-script tokens, plus what's automated vs.
still manual for a new tenant/project) see the infra repo's
`docker/TENANT_SETUP.md` — this section stays the authoritative source
for the frontend-specific "why", that doc is the authoritative source for
the complete list.
## Pages
@@ -170,7 +175,7 @@ explains what does have access instead.
| `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`, `categories` (`hasMany` relation to `categories` — a post can have several, joined with `", "` wherever rendered), `excerpt`, `thumbnail`, `content` (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via `app/components/RichText.tsx`'s `@payloadcms/richtext-lexical/react` renderer with custom `JSXConverters`), `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`), `seoTitle`/`seoDescription`/`seoImage` (each falls back to `title`/`excerpt`/`thumbnail` when empty, consumed by `/blog/[slug]`'s `generateMetadata()`) |
| `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). Each page's "Stand: …" line is derived from this doc's own `updatedAt` (`formatMonthYear()` in `app/lib/format.ts`), not a hand-typed string — it updates automatically the moment an admin edits the content, no separate field to remember |
| `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `contentPart2` (optional second richText, only populated where a dynamic section sits MID-document — currently just AGB's §2 Vertragspartner, `content` = §1, `contentPart2` = §3 onward), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF). Each page's "Stand: …" line is derived from this doc's own `updatedAt` (`formatMonthYear()` in `app/lib/format.ts`), not a hand-typed string — it updates automatically the moment an admin edits the content, no separate field to remember. Name/address/email are NOT hand-typed into any of these richTexts — `AnbieterAngaben.tsx` (Impressum), `VerantwortlicherBlock.tsx` (Datenschutz §1, prepended since it's the very first section) and `VertragspartnerBlock.tsx` (AGB §2, sits between `content` and `contentPart2` since it's mid-document) all render it live from `company-settings` instead, so it can't drift out of sync the way a second hand-typed copy did before (confirmed drifted at least once — AGB's seed script still had placeholder "Björn Wendt"/"Musterstraße 12" long after the real address was set live) |
| `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` |
@@ -654,13 +659,27 @@ All five call sites already fetch the full product server-side
`product.variants` and `product.outOfStock` are simply passed straight
through — no separate data-fetch needed for `AddToCartButton`'s two pages.
**Shared `ProductCard.tsx`**: `ProductGrid.tsx` (shop grid),
`RelatedProducts.tsx` (cart upsell), and `MerklisteGrid.tsx`
(`/konto/merkliste`) all render the same card via `app/components/
ProductCard.tsx` — each used to independently duplicate this markup and
had drifted (different image aspect ratios, a "Mehr erfahren" link
present on some but not others, no title link anywhere). The product
title is now the card's only link (`product.href`, when set) — the old
separate "Mehr erfahren" CTA line is gone, which is also what makes the
card more compact. `belowPrice` (optional `ReactNode`) is the one real
per-grid customization point — `ProductGrid.tsx` uses it for its
delivery-time line, the other two grids pass nothing. `topLeftBadge`
similarly lets `MerklisteGrid.tsx` override the default badge with its
own "Gekauft am …" pill for already-purchased items.
**Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives
`Product.outOfStock` (and each `variants[].outOfStock`) from
`trackInventory`/`stock`/`allowBackorder` — true only when inventory is
tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart
buttons disable themselves and show "Ausverkauft" for whichever variant is
currently selected (or the plain product, when there are no variants);
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing any
`ProductCard.tsx` additionally shows an "Ausverkauft" badge (replacing any
discount/low-stock badge — a sold-out product has nothing else useful to
show) once *every* variant of a product is out — one sold-out variant
among several just reads as such in the picker itself, not as a
@@ -676,7 +695,7 @@ exposed in the public `Product` type, only this derived boolean — the
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
verfügbar" **text line** (`text-warning`, the same `--color-warning` token
in `globals.css`, distinct from the brand-colored discount badge) next to
the price on `ProductGrid.tsx`/`ProductSpotlight.tsx`/`RelatedProducts.tsx`/
the price in `ProductCard.tsx`/`ProductSpotlight.tsx`/
todo-cards' `TodoKartenHero.tsx` and `Pricing.tsx` (both — the page has two
independent purchase CTAs, hero and pricing panel, each with their own
price/delivery block) and — variant-specific, not
@@ -688,17 +707,14 @@ Ausverkauft/discount — low-stock moved off the image into text on request,
since a shopper scanning product photos for "-20%" style badges reads a
long low-stock sentence pinned to the image as clutter, and a customer
already in the cart with that product had no low-stock signal there at
all before this. The earlier version of this text line (removed once,
see git history) broke equal-height card alignment in `ProductGrid.tsx`/
`RelatedProducts.tsx` by only rendering when `lowStock` was true, so
cards with/without the line ended up different heights; this version
always renders the line's slot (`min-h-[1.05rem]`, empty when not
low-stock) so every card in a row reserves the same space regardless of
state — `ProductGrid.tsx` additionally still has its `flex-1` spacer
pinning the add-to-cart button to the same Y as before, so the reserved
height is belt-and-suspenders there, but load-bearing in
`RelatedProducts.tsx`, which has no such spacer. Only "Ausverkauft" still
wins outright over the discount pill, since it replaces it.
all before this. `ProductCard.tsx` always renders the line's slot
(`min-h-[1.05rem]`, empty when not low-stock, unconditionally — even for
a fully-out-of-stock product, whose `NotifyMeForm` row is height-matched
against this exact reserved slot being present, see "Back-in-stock
notifications" below) so every card in a row reserves the same space
regardless of state, plus a `flex-1` spacer pinning the button/form to the
same Y across a row. Only "Ausverkauft" still wins outright over the
discount pill, since it replaces it.
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)`
a selected variant's `priceOverride` wins over the base `product.price`
@@ -1843,15 +1859,19 @@ building all of that by hand. `no @types/klaro` package exists —
disabled Add-to-cart button's slot entirely (not stacked below it) once a
product/variant is out of stock, POSTing to `/api/stock-notifications`
`app/lib/stockNotifications.ts` → the backend's `stock-notifications`
collection. Always shows the email input + submit button directly (no
collapse-to-reveal step). `AddToCartButton`/`AddToCartInlineButton` both
carry a `numericId` prop (`product.numericId`, the real Payload id — NOT
their own `id`/`productId` props, which are the commerce slug) threaded
through for this, same split `WishlistButton` already used.
`ProductGrid.tsx`/`MerklisteGrid.tsx`/`RelatedProducts.tsx` all use
`items-start` on their grid (not CSS Grid's stretch default) so an
out-of-stock card being taller than its siblings doesn't push their own
buttons out of alignment.
collection. Always shows the email input directly (no collapse-to-reveal
step) — rendered as ONE row, the submit control an icon button embedded
inside the input itself (absolutely positioned), not a separate button
stacked below it. Explicit `h-14` on the input matches
`AddToCartInlineButton`'s own button's actual rendered height, which
comes from its 30px cart-icon image rather than its padding alone — an
earlier two-row version (input above a full-width "Benachrichtigen"
button) made a sold-out `ProductCard` taller than its in-stock siblings;
matching heights exactly (not just approximately) was the actual ask.
`AddToCartButton`/`AddToCartInlineButton` both carry a `numericId` prop
(`product.numericId`, the real Payload id — NOT their own `id`/
`productId` props, which are the commerce slug) threaded through for
this, same split `WishlistButton` already used.
The email itself (`renderBackInStockHtml` in `app/lib/emailTemplates.ts`)
has its own dedicated Live Preview renderer, not the shared generic
@@ -1885,13 +1905,15 @@ anywhere.
## Product image gallery
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click,
backed by an optional `Products.gallery` field (backend, `hasMany` upload,
empty for most products — see the Payload README). Renders as a single
plain image with no thumbnail row at all when `gallery` is empty, i.e. the
same appearance every product already had before this existed — a product
only gets the gallery UI once someone deliberately adds extra photos in
the admin.
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
on mobile touch swipe (left/right, `onTouchStart`/`onTouchEnd`,
`SWIPE_THRESHOLD_PX`), backed by an optional `Products.gallery` field
(backend, `hasMany` upload, empty for most products — see the Payload
README). Renders as a single plain image with no thumbnail row (and no
swipe handling — nothing to swipe between) when `gallery` is empty, i.e.
the same appearance every product already had before this existed — a
product only gets the gallery UI once someone deliberately adds extra
photos in the admin.
There's no generic `/shop/[slug]` product detail page in this app — every
product beyond the plain grid card gets a bespoke hero page (currently