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