diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index 676405b..d6fd9c8 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -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

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 ? {textNode.text} : {textNode.text}; + } + if (node.type === "linebreak") return
; + // Any other inline node (link, etc.) — render its text children without + // the wrapper itself; this teaser has no use for an actual 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

{fallback}

; + } + return ( + <> + {root.children.map((paragraph, i) => ( +

+ {(paragraph.children ?? []).map((child, j) => renderInline(child, j))} +

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

- {/* 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. */} -

- {product.spotlightText || 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). */} diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 6fa5ef8..028f044 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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;