Wire up Products.layout (PDP page builder) frontend

New ProductBlocks.tsx renders a product's layout — delegates to
PageBlocks.tsx's renderPageBlockSync for the 9 block types shared with
Pages.layout, handles productHero/productPricingPanel itself (need live
product/shipping/tax context a generic content page doesn't have).

Converts /einfach-anfangen to render through this instead of its own
Hero/HowItWorks/Focus/Pricing components (now deleted) — the first PDP
built as CMS blocks end to end. der-eine/todo-cards/tasse-die-pause are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
This commit is contained in:
Marco
2026-08-26 23:12:26 +00:00
parent a2d4cb29fc
commit 1ab4088bf0
8 changed files with 283 additions and 336 deletions
+66
View File
@@ -269,6 +269,12 @@ export type Product = {
// related product's own relatedProduct is never resolved/shown) — see
// mapPayloadProduct's own comment.
relatedProduct: Product | null;
// Optional PDP page-builder content — see Products.ts's own `layout`
// field comment. Empty for every existing hand-coded PDP (der-eine,
// todo-cards, tasse-die-pause); a product's own page.tsx decides whether
// to render this or its bespoke components, this type doesn't enforce
// either.
layout: ProductBlock[];
};
type PayloadProduct = {
@@ -276,6 +282,7 @@ type PayloadProduct = {
name: string;
subline: string | null;
slug: string;
layout: PayloadProductBlock[] | null;
description: unknown | null;
sku: string | null;
price: number;
@@ -399,6 +406,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
// this nested object's OWN relatedProduct is never populated (stays a
// raw id or absent), so the recursion bottoms out after exactly one level.
relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
layout: (product.layout ?? []).map(mapPayloadProductBlock),
};
}
@@ -1318,6 +1326,64 @@ export async function getTrackingCodes(): Promise<TrackingCode[]> {
return Array.isArray(data.docs) ? data.docs : [];
}
// ── Products.layout (PDP page builder) ─────────────────────────────────────
// Shares 9 of PageBlock's own block types verbatim (richTextSection/
// stepRow/quote/image/icon/pillList/checklistImage/table/ctaCard — same
// Payload Block configs, reused a second time on Products.layout, see that
// field's own comment) plus two product-specific ones that don't exist on
// Pages: productHero (the one PDP hero variant for now — just a body
// richText, everything else about the hero comes from the product itself)
// and productPricingPanel (the closing buy panel, no fields of its own).
export type ProductBlock =
| { blockType: "productHero"; id: string; body: unknown | null }
| { blockType: "productPricingPanel"; id: string }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label: string | null }
| { blockType: "image"; id: string; image: string | null; caption: string | null }
| { blockType: "icon"; id: string; icon: string }
| { 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: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string };
type PayloadProductBlock =
| { blockType: "productHero"; id: string; body?: unknown | null }
| { blockType: "productPricingPanel"; id: string }
| { blockType: "richTextSection"; id: string; content: unknown }
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
| { blockType: "quote"; id: string; text: string; label?: string | null }
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
| { blockType: "icon"; id: string; icon: string }
| { 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: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string };
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
switch (block.blockType) {
case "productHero":
return { blockType: "productHero", id: block.id, body: block.body ?? null };
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 };
case "stepRow":
return {
blockType: "stepRow",
id: block.id,
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
};
default:
return block;
}
}
// ── 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).