feat(blog): optional quote label + swappable related-product card per post

Both were fully hardcoded before: every blockquote showed a static
"Merke dir:" label, and every post's "Passend dazu" card always linked
the same flagship product. Now driven by two new Posts fields —
quoteLabel (empty hides the label/icon/underline, blockquote still
renders) and relatedProduct (empty hides the card entirely) — mirroring
Products.spotlight but per-post instead of a single site-wide flag.

README's collection table updated to match today's Payload changes
(shipping-settings, the new Posts fields, trust-badges' placeholder
tokens, admin sidebar grouping) — also fixed a stale claim that `media`
isn't tenant-scoped; it already was.
This commit is contained in:
Marco
2026-07-21 13:10:06 +00:00
parent 225a8567a9
commit 2d6cff9f40
4 changed files with 140 additions and 90 deletions
+11 -4
View File
@@ -70,15 +70,22 @@ 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` |
| `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`, `icon`, `sortOrder` |
| `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`, `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-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` |
| `media` | Shared upload collection (not tenant-scoped) backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) |
| `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`);
`media`/`users`/`tenants` sit under **Platform**.
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
+48 -44
View File
@@ -94,52 +94,56 @@ export default async function BlogDetailPage({
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} />
<RichText content={post.content} quoteLabel={post.quoteLabel} />
{/* "Passend dazu" — static cross-promo, not CMS content (same
reasoning as the legal pages' brand callouts): every post
currently points at the same flagship product rather than
needing a per-post "related product" field that nothing else
uses yet. Matches the actual built Figma frame exactly (node
4674:349, fetched via get_design_context) — 1px border-border,
rounded-md, px-9/py-7 padding; an earlier version guessed a
plain borderless row instead, which get_metadata's structural
dump didn't reveal (frame-level stroke/padding/radius aren't
visible there, only get_design_context shows those). */}
<Link
href="/todo-cards"
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
>
<Image alt="" src="/icon-todo-passend-dazu.png" width={64} height={75} className="w-16 h-[4.6875rem] shrink-0 object-contain" />
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
<div className="flex items-end justify-between gap-4 w-full">
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
so the description wraps at the same point instead of
stretching out to fill the space before "Entdecken". */}
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
<p
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
Entdecken
<svg
viewBox="0 0 20 20"
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
not a hardcoded flagship-product link. Hidden entirely if the
post has no related product, or that product has no detail
page to send "Entdecken" to. Matches the actual built Figma
frame exactly (node 4674:349, fetched via get_design_context) —
1px border-border, rounded-md, px-9/py-7 padding; an earlier
version guessed a plain borderless row instead, which
get_metadata's structural dump didn't reveal (frame-level
stroke/padding/radius aren't visible there, only
get_design_context shows those). */}
{post.relatedProduct?.href && (
<Link
href={post.relatedProduct.href}
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
>
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
</div>
</div>
</Link>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
<div className="flex items-end justify-between gap-4 w-full">
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
so the description wraps at the same point instead of
stretching out to fill the space before "Entdecken". */}
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
<p
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
{post.relatedProduct.name}
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.description}</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
Entdecken
<svg
viewBox="0 0 20 20"
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</Link>
)}
</Reveal>
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
+49 -32
View File
@@ -64,12 +64,12 @@ export function extractHeadings(content: unknown): TOCSection[] {
return headings;
}
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
if (!nodes) return null;
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
}
function renderNode(node: LexicalNode, key: string): ReactNode {
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
switch (node.type) {
case "linebreak":
return <br key={key} />;
@@ -88,7 +88,7 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
href={node.fields?.url ?? "#"}
className="text-brand hover:underline"
>
{renderChildren(node.children, key)}
{renderChildren(node.children, key, quoteLabel)}
</a>
);
case "heading": {
@@ -101,7 +101,7 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{renderChildren(node.children, key)}
{renderChildren(node.children, key, quoteLabel)}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</Tag>
);
@@ -116,18 +116,18 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
}
>
{renderChildren(node.children, key)}
{renderChildren(node.children, key, quoteLabel)}
</ListTag>
);
}
case "listitem":
return (
<li key={key}>{renderChildren(node.children, key)}</li>
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
);
case "paragraph":
return (
<p key={key} className="text-body text-text-body">
{renderChildren(node.children, key)}
{renderChildren(node.children, key, quoteLabel)}
</p>
);
// Lexical's default blockquote feature — used sitewide as a "Merke
@@ -146,26 +146,33 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
case "quote":
return (
<div key={key} className="relative flex items-start gap-6 w-full my-6">
<div className="flex items-center gap-2 shrink-0">
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
</span>
<span
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Merke dir:
</span>
</div>
{/* Hand-drawn underline image, not a plain bar — exported
straight from the Figma node (label-underline). */}
<Image
alt=""
src="/icon-merke-dir-underline.png"
width={136}
height={23}
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
/>
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
empty, only the divider + quote text render. The blockquote
itself is never optional, just this framing around it. */}
{quoteLabel && (
<>
<div className="flex items-center gap-2 shrink-0">
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
</span>
<span
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
{quoteLabel}
</span>
</div>
{/* Hand-drawn underline image, not a plain bar — exported
straight from the Figma node (label-underline). */}
<Image
alt=""
src="/icon-merke-dir-underline.png"
width={136}
height={23}
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
/>
</>
)}
<div className="w-px self-stretch bg-brand shrink-0" />
{/* Lexical's real QuoteNode holds flat text/linebreak children
directly, NOT nested paragraphs — pressing Enter inside a
@@ -181,22 +188,32 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
>
{renderChildren(node.children, key)}
{renderChildren(node.children, key, quoteLabel)}
</p>
</div>
);
default:
return renderChildren(node.children, key);
return renderChildren(node.children, key, quoteLabel);
}
}
export function RichText({ content }: { content: unknown }) {
export function RichText({
content,
quoteLabel = "Merke dir:",
}: {
content: unknown;
/** Label for any blockquote's callout (see the "quote" case above) —
* defaults to "Merke dir:" for callers that don't pass one (legal pages
* never use blockquotes, so this only actually matters for blog posts).
* Pass "" to hide the label/icon/underline for every blockquote here. */
quoteLabel?: string;
}) {
const root = (content as { root?: LexicalNode })?.root;
if (!root?.children) return null;
return (
<div className="flex flex-col gap-4 w-full">
{renderChildren(root.children, "root")}
{renderChildren(root.children, "root", quoteLabel)}
</div>
);
}
+32 -10
View File
@@ -71,9 +71,21 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
export type PostDetail = BlogPost & {
content: unknown;
/** Label for every blockquote callout in this post's content (icon +
* underline included) — empty string hides that left side entirely,
* the blockquote itself still renders. See RichText.tsx's "quote" case. */
quoteLabel: string;
/** Powers the "Passend dazu" card at the end of the post — null hides
* the card entirely, per-post choice (unlike Products.spotlight, which
* is a single site-wide flag). */
relatedProduct: Product | null;
};
type PayloadPostDetail = PayloadPost & { content: unknown };
type PayloadPostDetail = PayloadPost & {
content: unknown;
quoteLabel: string | null;
relatedProduct: PayloadProduct | null;
};
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
const params = new URLSearchParams({
@@ -107,6 +119,8 @@ export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
content: doc.content,
publishedAt: doc.publishedAt,
featured: doc.featured,
quoteLabel: doc.quoteLabel ?? "",
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
};
}
@@ -136,6 +150,22 @@ type PayloadProduct = {
detailHref: string | null;
};
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
// about eliminating elsewhere.
function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
name: product.name,
description: product.description ?? "",
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
};
}
export async function getProducts(): Promise<Product[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
@@ -154,15 +184,7 @@ export async function getProducts(): Promise<Product[]> {
const data: { docs?: PayloadProduct[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((product) => ({
id: product.slug,
name: product.name,
description: product.description ?? "",
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
}));
return docs.map(mapPayloadProduct);
}
export async function getProductBySlug(slug: string): Promise<Product | null> {