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:
Marco
2026-08-26 20:08:24 +00:00
parent 940545888c
commit 885389b300
6 changed files with 451 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { PageBlocks } from "../components/PageBlocks";
import { LivePageContent } from "../components/LivePageContent";
import { getPageBySlug, getCompanySettings } from "../lib/payload";
import { buildWebPageSchema } from "../lib/structuredData";
// First generic catch-all content route in this repo — every other page
// (blog posts excepted, which have their own [slug]) is its own static
// directory under app/. Powers Payload's new Pages collection (the page
// builder): a doc's `layout` blocks field renders via PageBlocks.tsx,
// structurally parallel to how RichText.tsx renders Posts.content's own
// (differently-shaped) Blocks. Next.js resolves any matching static route
// (e.g. app/lebensuhr/page.tsx) before ever reaching this catch-all, so a
// Pages document only actually serves a URL once the static file for that
// slug, if any, is removed.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const page = await getPageBySlug(slug);
if (!page) return { title: "Seite nicht gefunden" };
const title = page.seoTitle || page.title;
const description = page.seoDescription ?? undefined;
return {
title,
description,
alternates: { canonical: `/${page.slug}` },
openGraph: {
title,
description,
url: `/${page.slug}`,
type: "website",
images: page.seoImage ? [{ url: page.seoImage }] : undefined,
},
twitter: {
title,
description,
images: page.seoImage ? [page.seoImage] : undefined,
},
};
}
export default async function DynamicPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { isEnabled: isPreview } = await draftMode();
const page = await getPageBySlug(slug, { draft: isPreview });
if (!page) notFound();
const seller = await getCompanySettings();
const pageSchema = buildWebPageSchema(page, seller);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(pageSchema) }} />
<main className="flex flex-col flex-1 bg-bg-base">
{isPreview ? (
<LivePageContent initialPage={page} />
) : (
<>
<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">
<PageBlocks blocks={page.layout} />
</Reveal>
</>
)}
</main>
<Footer />
</>
);
}
+46
View File
@@ -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>
</>
);
}
+168
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+106
View File
@@ -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);
}
+20
View File
@@ -100,3 +100,23 @@ export function buildArticleSchema(
// directly (no need for a shared component around one line of JSX, and
// keeps this file free of "use client"/React concerns so server
// components can import it without issue).
// Generic fallback for content pages with no bespoke schema type of their
// own (Article for blog posts, Product for PDPs) — the Payload Pages
// collection (page builder) serves arbitrary content types, so a generic
// WebPage node is the right shape rather than guessing at a more specific
// one from the blocks it happens to contain.
export function buildWebPageSchema(
page: { title: string; seoDescription: string | null; slug: string },
seller: CompanySettings | null,
): Record<string, unknown> {
const url = `${SITE_URL}/${page.slug}`;
return {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.title,
url,
...(page.seoDescription ? { description: page.seoDescription } : {}),
isPartOf: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
};
}