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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user