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:
@@ -1283,3 +1283,109 @@ export async function getTrackingCodes(): Promise<TrackingCode[]> {
|
||||
const data: { docs?: TrackingCode[] } = await res.json();
|
||||
return Array.isArray(data.docs) ? data.docs : [];
|
||||
}
|
||||
|
||||
// ── Pages (generic slug-routed content pages / page builder) ──────────────
|
||||
// Powers app/[slug]/page.tsx — the first generic catch-all route in this
|
||||
// repo (every other content page today is its own static directory).
|
||||
// `layout` is a Payload native `type: 'blocks'` field (docker/payload/src/
|
||||
// collections/Pages.ts), a different shape from Posts.content's Lexical-
|
||||
// embedded Blocks (see RichText.tsx's own comment) — each block here is a
|
||||
// full page section, not an inline prose element.
|
||||
|
||||
export type PageBlock =
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label: string | null }
|
||||
| { blockType: "image"; id: string; image: string | null; caption: string | null }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string };
|
||||
|
||||
export type Page = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
layout: PageBlock[];
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
seoImage: string | null;
|
||||
};
|
||||
|
||||
type PayloadPageImageField = { url?: string | null } | number | null;
|
||||
|
||||
type PayloadPageBlock =
|
||||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; description: string }[] }
|
||||
| { blockType: "quote"; id: string; text: string; label?: string | null }
|
||||
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
|
||||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||||
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
|
||||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string };
|
||||
|
||||
export type PayloadPage = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
layout: PayloadPageBlock[];
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
seoImage?: { url: string } | number | null;
|
||||
};
|
||||
|
||||
function mapPayloadPageImage(ref: PayloadPageImageField): string | null {
|
||||
return typeof ref === "object" && ref && typeof ref.url === "string" ? ref.url : null;
|
||||
}
|
||||
|
||||
function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
|
||||
switch (block.blockType) {
|
||||
case "image":
|
||||
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
|
||||
case "checklistImage":
|
||||
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
|
||||
case "quote":
|
||||
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
|
||||
case "ctaCard":
|
||||
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
|
||||
default:
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by getPageBySlug() and LivePageContent.tsx (re-maps the raw
|
||||
// document useLivePreview receives via postMessage using this same logic)
|
||||
// — same reuse pattern as mapPayloadPost.
|
||||
export function mapPayloadPage(doc: PayloadPage): Page {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
layout: (doc.layout ?? []).map(mapPayloadPageBlock),
|
||||
seoTitle: doc.seoTitle || null,
|
||||
seoDescription: doc.seoDescription || null,
|
||||
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPageBySlug(slug: string, options?: { draft?: boolean }): Promise<Page | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[slug][equals]": slug,
|
||||
depth: "2",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||||
if (!res.ok) {
|
||||
console.error(`getPageBySlug: Payload returned ${res.status} ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: { docs?: PayloadPage[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
return mapPayloadPage(doc);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user