Add the page-builder frontend: /[slug] catch-all + block renderer
Pages/getPageBySlug/mapPayloadPage in payload.ts follow the exact getPostBySlug/mapPayloadPost pattern. PageBlocks.tsx renders a Pages doc's `layout` field, reusing existing components (RichText, StepArrow, STEP_ICONS, TestimonialsGrid) rather than reinventing per- block styling — matches what /lebensuhr, /3x3-system, and /7-tage-klarheits-check already hand-built. LivePageContent.tsx mirrors LivePostContent.tsx's live-preview pattern, using a synchronous subset of the block renderer (testimonialsRef needs an async fetch a client component can't perform inline, so it's skipped in preview only — same "editable subset" scope-cut LivePostContent.tsx already makes). buildWebPageSchema in structuredData.ts is the generic JSON-LD fallback for content types with no bespoke schema (Article/ Product don't fit a page-builder page). Verified end-to-end: inserted a real Pages test document (SQL, since no admin auth available here) covering richTextSection/quote/ctaCard, confirmed /[slug] renders it correctly, confirmed notFound() after deleting it, then cleaned up the test row.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { mapPayloadPage, type PayloadPage, type Page } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of a Pages document: breadcrumb/H1 and the
|
||||
// layout blocks — same "editable subset only" scope as LivePostContent.tsx
|
||||
// (see that file's own comment). `testimonialsRef` blocks are skipped here
|
||||
// (see renderPageBlockSync's own comment) since they need an async fetch a
|
||||
// client component can't perform inline; editing that block still shows up
|
||||
// once the page is saved and reloaded outside the preview iframe.
|
||||
export function LivePageContent({ initialPage }: { initialPage: Page }) {
|
||||
const { data } = useLivePreview<PayloadPage>({
|
||||
initialData: initialPage as unknown as PayloadPage,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const page = data?.slug ? mapPayloadPage(data) : initialPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{page.title}</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{page.title}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
|
||||
{page.layout.map(renderPageBlockSync)}
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PageBlock } from "../lib/payload";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { RichText } from "./RichText";
|
||||
import { Quote } from "./Quote";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS } from "./icons/StepIcons";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Pages document's `layout` blocks field — structurally
|
||||
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
|
||||
// level up (full page sections, not inline Lexical nodes). Each block
|
||||
// component reuses the exact same visual treatment already hand-built on
|
||||
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
|
||||
// inventing new styling — this is what those pages' repeated patterns get
|
||||
// consolidated into for CMS-driven pages going forward.
|
||||
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
|
||||
const rendered = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
|
||||
}
|
||||
return renderPageBlockSync(block);
|
||||
})
|
||||
);
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
|
||||
// which a client component can't await — see LivePageContent.tsx, which
|
||||
// uses this directly and simply skips that one block type in preview,
|
||||
// same "live-previewable subset" scope-cut LivePostContent.tsx already
|
||||
// makes for its own author-bio/"Weiterlesen" chrome).
|
||||
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "richTextSection":
|
||||
return <RichText key={block.id} content={block.content} />;
|
||||
|
||||
case "quote":
|
||||
return <Quote key={block.id}>{block.text}</Quote>;
|
||||
|
||||
case "image":
|
||||
if (!block.image) return null;
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
|
||||
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
|
||||
</div>
|
||||
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "pillList":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-wrap gap-2">
|
||||
{block.items.map((item) => (
|
||||
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checklistImage":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{block.image && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<div key={block.id} 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">{block.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={row.id} 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>
|
||||
);
|
||||
|
||||
case "stepRow": {
|
||||
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{block.items.flatMap((item, i) => [
|
||||
<div key={item.id} 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>
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < block.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id}`} 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>
|
||||
);
|
||||
}
|
||||
|
||||
case "testimonialsRef":
|
||||
// Needs an async fetch — handled by PageBlocks (server) above.
|
||||
// Skipped in the client-side live-preview renderer.
|
||||
return null;
|
||||
|
||||
case "ctaCard":
|
||||
return (
|
||||
<Link
|
||||
key={block.id}
|
||||
href={block.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">{block.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
|
||||
{block.description && <p className="text-body-sm text-text-muted">{block.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>
|
||||
);
|
||||
|
||||
default:
|
||||
// Unknown/malformed block — same "skip rather than crash" convention
|
||||
// RichText.tsx's own blocks use.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
|
||||
// checklists.
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Same visual language as RichText.tsx's Quote converter (Caveat script +
|
||||
// brand divider) — extracted here so PageBlocks.tsx (and any future hand-
|
||||
// written page) can reuse it instead of each page defining its own copy,
|
||||
// which is what /lebensuhr, /3x3-system, and /ueber-mich each did before
|
||||
// this existed.
|
||||
export function Quote({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-2">
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p className="text-text-primary text-[1.75rem] leading-[1.1] flex-1" style={{ fontFamily: "var(--font-caveat)" }}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user