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
+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>
);
}