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
+204
View File
@@ -0,0 +1,204 @@
import Link from "next/link";
import Image from "next/image";
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartButton } from "./AddToCartButton";
import { ProductName } from "./ProductName";
import { RichText } from "./RichText";
import { renderPageBlockSync } from "./PageBlocks";
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
// this delegates to directly for every block type the two fields share
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
// ctaCard are the exact same Block configs, just registered a second time
// on Products.layout — see that field's own comment in Products.ts). Only
// `productHero` and `productPricingPanel` are product-specific, since they
// need the live product/shipping/tax context that a generic content page
// doesn't have.
export async function ProductBlocks({ product }: { product: Product }) {
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
return (
<>
{product.layout.map((block) => (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6 first:pt-10 md:first:pt-12">
{renderProductBlock(block, ctx)}
</div>
))}
</>
);
}
type ProductBlockContext = {
product: Product;
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
taxRate: number;
kleinunternehmer: boolean;
};
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
switch (block.blockType) {
case "productHero":
return <ProductHero {...ctx} body={block.body} />;
case "productPricingPanel":
return <ProductPricingPanel {...ctx} />;
// Every other block type is identical to Pages.layout's own — same
// Block config, reused a second time (see this file's own comment).
default:
return renderPageBlockSync(block as PageBlock);
}
}
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)]">
<div className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start">
<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>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">
<ProductName name={product.name} />
</span>
</p>
<div className="flex flex-col gap-2 items-start w-full">
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
<ProductName name={product.name} />
</p>
{product.subline && (
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{product.subline}
</p>
)}
</div>
{body ? <RichText content={body} /> : null}
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
<AddToCartButton
label="In den Warenkorb"
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
<div className="order-0 lg:order-none lg:col-span-7 relative w-full aspect-[16/10] lg:aspect-auto lg:min-h-[28rem] bg-bg-muted overflow-hidden rounded-md">
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 58vw, 100vw" className="object-cover" />
</div>
</div>
);
}
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<div className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
const bullets = [
"10 ToDo-Karten",
"Platz für bis zu 7 Aufgaben",
"7,5 × 12,5 cm passt in jede Tasche",
"Freie Rückseite für Notizen",
"Kleine Trink-Erinnerung auf jeder Karte",
"Hochwertiges Papier",
"Produziert in Deutschland",
];
// Spec bullet list — same "hand-written, not modeled in Products" reasoning
// as todo-cards/Pricing.tsx's own bullets. Two-column at sm+ since 7 items
// in one column ran noticeably longer than every other section on the page.
export function Focus() {
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center mb-10">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
10 Karten für deinen Alltag
</p>
<p className="text-body text-text-muted max-w-[42rem] w-full text-left md:text-center">
Eine Karte für heute. Für das, was gerade ansteht.
</p>
</Reveal>
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-3 w-full max-w-[36rem] mx-auto">
{bullets.map((b) => (
<RevealItem key={b} className="flex gap-2 items-center">
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
<span className="text-body-sm text-text-primary">{b}</span>
</RevealItem>
))}
</RevealGroup>
</section>
);
}
-120
View File
@@ -1,120 +0,0 @@
import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { ProductName } from "../../components/ProductName";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Same "compact early teaser + delivery-time repeated next to every buy
// button" reasoning as TodoKartenHero.tsx/der-eine's Hero.tsx. Body copy is
// hardcoded here (not read from product.description) — same convention as
// those two, see der-eine/page.tsx's own comment on why the CMS field and
// this page's tuned prose are allowed to say different things.
export async function Hero() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("todo-starter"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
const fullyOutOfStock = product
? !product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock)
: false;
const anyLowStock = product
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
: false;
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<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>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">{product ? <ProductName name={product.name} /> : "Einfach anfangen."}</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<div className="flex flex-col gap-2 items-start w-full">
<p
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
{product ? <ProductName name={product.name} /> : "Einfach anfangen."}
</p>
{product?.subline && (
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.subline}
</p>
)}
</div>
<div className="flex flex-col gap-4">
<p className="text-body text-text-body">
Nicht alles. Nicht irgendwann. Nur die paar Dinge, die heute wichtig sind.
</p>
<p className="text-body text-text-body">
Einfach anfangen. sind ToDo-Karten für deinen Alltag mit Platz für bis zu sieben Aufgaben, einem kleinen Feld für Das war heute schön" und einer freien Rückseite für Notizen.
</p>
<p className="text-body text-text-body">
Dazu eine kleine Trink-Erinnerung. Weil man das zwischen all den anderen Dingen gern mal vergisst.
</p>
</div>
<div className="flex flex-col gap-1 items-start">
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
{product && (
<AddToCartButton
label="In den Warenkorb"
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
numericId={product.numericId}
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
)}
</div>
</Reveal>
<div className="order-0 lg:order-none lg:col-span-7 relative w-full aspect-[16/10] lg:aspect-auto lg:min-h-[28rem] bg-bg-muted overflow-hidden">
{product && <Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 58vw, 100vw" className="object-cover" />}
</div>
</div>
</section>
);
}
@@ -1,68 +0,0 @@
import { Fragment } from "react";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Same icon-step-*.png set and layout as todo-cards/components/HowItWorks.tsx
// — write / choose-and-start / check off maps onto the same three glyphs.
const steps = [
{
icon: "/icon-step-1.png",
width: 165,
height: 177,
title: "1. Aufschreiben",
desc: "Was steht heute an?",
},
{
icon: "/icon-step-2.png",
width: 180,
height: 168,
title: "2. Anfangen",
desc: "Eine Sache auswählen und loslegen.",
},
{
icon: "/icon-step-3.png",
width: 180,
height: 177,
title: "3. Abhaken",
desc: "Was erledigt ist, darf weg. Der Rest kommt später.",
},
];
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Eine Karte. Ein Tag.
</p>
</Reveal>
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
{steps.map((step, i) => (
<Fragment key={step.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
<Image
src={step.icon}
alt=""
width={step.width}
height={step.height}
className="h-16 w-auto object-contain transition-transform duration-300 group-hover:scale-110"
/>
<p className="font-semibold text-body text-text-primary">{step.title}</p>
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
</RevealItem>
{i < steps.length - 1 && (
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
</div>
)}
</Fragment>
))}
</RevealGroup>
</section>
);
}
@@ -1,92 +0,0 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { ProductName } from "../../components/ProductName";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Same closing-CTA-panel pattern as todo-cards/components/Pricing.tsx.
export async function Pricing() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("todo-starter"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</p>
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+12 -14
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Pricing } from "./components/Pricing";
import { notFound } from "next/navigation";
import { ProductBlocks } from "../components/ProductBlocks";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
@@ -24,25 +22,25 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
// First PDP driven entirely by Products.layout (see that field's own
// comment in Products.ts) instead of its own bespoke Hero/HowItWorks/
// Focus/Pricing components — those were deleted once this product's
// content was authored as blocks in the admin (see the PDP page-builder
// rollout). der-eine/todo-cards/tasse-die-pause are untouched and keep
// their own hand-coded components; nothing about them depends on this.
export default async function EinfachAnfangenPage() {
const [product, seller] = await Promise.all([
getProductBySlug("todo-starter"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/einfach-anfangen", seller)
: null;
if (!product) notFound();
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/einfach-anfangen", seller);
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<main className="flex flex-col flex-1">
<Hero />
<HowItWorks />
<Focus />
<Pricing />
<ProductBlocks product={product} />
</main>
<Footer />
</>
+1
View File
@@ -7,6 +7,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
numericId: 1,
name: "ToDo-Karten",
subline: null,
layout: [],
description: null,
descriptionText: "",
sku: null,
+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).