Allow bold formatting in the spotlight product's marketing text

Products.spotlightText switches from a plain textarea to a richText field
(Lexical, same default editor as every other richText field here) so
admins get a formatting toolbar. Rendered via a small dedicated inline
converter (bold + paragraphs only) rather than the full block-oriented
RichText.tsx, which is styled for article-length content (blog posts,
legal pages) and would add heading/paragraph spacing this teaser doesn't
want.
This commit is contained in:
Marco
2026-08-25 12:50:00 +00:00
parent eb160cac61
commit 4fa80d6bfb
2 changed files with 48 additions and 10 deletions
+40 -8
View File
@@ -1,3 +1,4 @@
import { Fragment, type ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
@@ -7,6 +8,44 @@ import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, get
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
type LexicalTextNode = { type: "text"; text: string; format?: number };
type LexicalNode = { type: string; text?: string; format?: number; children?: LexicalNode[] };
// Minimal Lexical JSON → JSX converter for spotlightText — deliberately NOT
// the full block-oriented RichText.tsx (Posts/LegalPages' renderer, with
// its own heading/paragraph spacing and image/quote blocks). This teaser
// only ever needs paragraphs of plain text with optional bold spans
// (Lexical's text `format` bitmask: 1 = bold), rendered inline to match
// this section's own <p className="text-body ..."> treatment rather than
// picking up article-level block styling.
function renderInline(node: LexicalNode, key: number): ReactNode {
if (node.type === "text") {
const textNode = node as LexicalTextNode;
const bold = typeof textNode.format === "number" && (textNode.format & 1) === 1;
return bold ? <strong key={key}>{textNode.text}</strong> : <Fragment key={key}>{textNode.text}</Fragment>;
}
if (node.type === "linebreak") return <br key={key} />;
// Any other inline node (link, etc.) — render its text children without
// the wrapper itself; this teaser has no use for an actual <a> here.
return (node.children ?? []).map((child, i) => renderInline(child, i));
}
function SpotlightText({ content, fallback }: { content: unknown; fallback: string }) {
const root = (content as { root?: { children?: LexicalNode[] } } | null)?.root;
if (!root?.children?.length) {
return <p className="text-body text-text-body whitespace-pre-line">{fallback}</p>;
}
return (
<>
{root.children.map((paragraph, i) => (
<p key={i} className="text-body text-text-body">
{(paragraph.children ?? []).map((child, j) => renderInline(child, j))}
</p>
))}
</>
);
}
/**
* Product teaser for whichever product is marked `spotlight` in Payload
* (defaults to none — the section just doesn't render until one is set),
@@ -86,14 +125,7 @@ export async function ProductSpotlight() {
>
{product.spotlightHeadline || product.name}
</p>
{/* whitespace-pre-line — spotlightText is a plain textarea field,
and admin-entered paragraph breaks (e.g. the "Die Sieben"
spotlight copy) should render as actual line breaks instead of
collapsing into one run-on line like default HTML whitespace
handling would. */}
<p className="text-body text-text-body whitespace-pre-line">
{product.spotlightText || product.description}
</p>
<SpotlightText content={product.spotlightText} fallback={product.description} />
{/* MwSt./Versand disclosure on its own line, not crammed into
the price row itself — same reasoning as todo-cards'
Pricing.tsx (identical text, same narrow-column risk). */}
+8 -2
View File
@@ -203,7 +203,13 @@ export type Product = {
spotlight: boolean;
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
// Lexical richText JSON (switched from plain textarea 2026-08-25 so
// admins can bold parts of the teaser) — rendered by
// ProductSpotlight.tsx's own minimal inline converter, not the full
// article-oriented RichText.tsx (that one adds block-level heading/
// paragraph spacing meant for blog posts/legal pages, wrong fit for a
// single teaser paragraph).
spotlightText: unknown | null;
spotlightImage: string | null;
// Per-product opt-in for a wishlist heart on the homepage spotlight —
// independent of (in addition to) the global wishlistEnabled toggle,
@@ -262,7 +268,7 @@ type PayloadProduct = {
spotlight: boolean;
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightText: unknown | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;