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:
+49
-32
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user