Render the 6 new blog-post blocks (StepRow/Icon/PillList/ChecklistImage/Table/CtaCard)
Follows Posts.content's BlocksFeature update (docker/payload commit 5426a02) — mirrors PageBlocks.tsx's own JSX for each block field-for-field since it's the same Block config reused a second time. Works in both the server-rendered page and the client-side Live Preview renderer (shared converters), unlike testimonialsRef which needs an async fetch and stays page-builder only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
@@ -89,12 +92,29 @@ type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
type IconBlockFields = { icon: string };
|
||||
type PillListBlockFields = { items: { id?: string | null; label: string }[] };
|
||||
type ChecklistImageBlockFields = { image: MediaRef; items: { id?: string | null; text: string }[] };
|
||||
type TableBlockFields = { labelHeader: string; valueHeader: string; rows: { id?: string | null; label: string; value: string }[] };
|
||||
type StepRowBlockFields = {
|
||||
items: { id?: string | null; icon: string; title: string; subtitle?: string | null; description: string }[];
|
||||
};
|
||||
type CtaCardBlockFields = { eyebrow: string; title: string; description?: string | null; href: string };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same checkmark as PageBlocks.tsx's own — see that file's comment.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
@@ -250,6 +270,121 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
// Same page-builder blocks as PageBlocks.tsx (Pages.layout) — see
|
||||
// that file's own comment on each block's visual treatment, mirrored
|
||||
// here field-for-field since it's the exact same Block config
|
||||
// registered a second time (Posts.content's BlocksFeature) so blog
|
||||
// posts can use them mid-article too. `testimonialsRef` is the one
|
||||
// page-builder block deliberately NOT included: it needs an async
|
||||
// data fetch, and this converter set is shared with LiveRichText's
|
||||
// client-side live-preview rendering (see blog's LivePostContent.tsx),
|
||||
// where an async component would break — PageBlocks.tsx only gets
|
||||
// away with it by awaiting outside the sync per-block switch, in its
|
||||
// own async server component.
|
||||
icon: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as IconBlockFields;
|
||||
const SymbolIcon = SYMBOLIC_ICONS[fields.icon];
|
||||
return SymbolIcon ? <div>{SymbolIcon()}</div> : null;
|
||||
},
|
||||
pillList: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as PillListBlockFields;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.items.map((item, i) => (
|
||||
<span key={item.id ?? i} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
checklistImage: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ChecklistImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{url && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={url} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{fields.items.map((item, i) => (
|
||||
<li key={item.id ?? i} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as TableBlockFields;
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.rows.map((row, i) => (
|
||||
<tr key={row.id ?? i} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
stepRow: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as StepRowBlockFields;
|
||||
const icons = fields.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{fields.items.flatMap((item, i) => [
|
||||
<div key={item.id ?? i} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < fields.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id ?? i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ctaCard: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as CtaCardBlockFields;
|
||||
return (
|
||||
<Link
|
||||
href={fields.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{fields.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{fields.title}</p>
|
||||
{fields.description && <p className="text-body-sm text-text-muted">{fields.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary 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>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user