From e7ad9cd88db142663ebcd2195822b6da5c1db4fb Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Aug 2026 08:07:51 +0000 Subject: [PATCH] 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 --- README.md | 84 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index cb77f48..671d873 100644 --- a/README.md +++ b/README.md @@ -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