Match block-driven PDP hero/pricing panel to hand-coded siblings visually

Reported: einfach-anfangen's block-built PDP looked visually far off from
todo-cards. Root cause: ProductBlocks.tsx wrapped every block (including
the hero) in one generic padded div, so the hero image lost its
edge-to-edge desktop bleed/hover-scale and neither section had its own
Reveal scroll-in animation, unlike every hand-coded PDP. ProductHero and
ProductPricingPanel now render their own full <section> matching
todo-cards' Hero.tsx/Pricing.tsx exactly (own Reveal, own grid, own
padding) instead of relying on the shared per-block wrapper, which now
only applies to genuinely generic content blocks.

Also wires up the testimonialsRef block for Products.layout (added to the
backend in commit 82583a1) — same async-fetch handling as PageBlocks.tsx,
plus the 'einfach-anfangen' TestimonialsPage value.

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:24:14 +00:00
parent 1ab4088bf0
commit e882b8b6ac
2 changed files with 119 additions and 70 deletions
+116 -69
View File
@@ -1,13 +1,15 @@
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 { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } 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 { Reveal } from "./Reveal";
import { renderPageBlockSync } from "./PageBlocks";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
@@ -26,15 +28,37 @@ export async function ProductBlocks({ product }: { product: Product }) {
]);
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">
const rendered = await Promise.all(
product.layout.map(async (block) => {
// Needs its own async data fetch — same reason PageBlocks.tsx
// handles this one block type in its own outer async map instead of
// the sync switch below (renderProductBlock/renderPageBlockSync).
if (block.blockType === "testimonialsRef") {
const testimonials = await getTestimonials(block.page);
if (testimonials.length === 0) return null;
return (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
<TestimonialsGrid testimonials={testimonials} />
</div>
);
}
// productHero/productPricingPanel render their own full section
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
// those, unlike a generic content block, so they opt out of this
// shared wrapper entirely rather than fighting it.
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
}
return (
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
{renderProductBlock(block, ctx)}
</div>
))}
</>
</Reveal>
);
})
);
return <>{rendered}</>;
}
type ProductBlockContext = {
@@ -57,6 +81,11 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac
}
}
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
// section/grid/Reveal/padding structure, same edge-to-edge image with
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
// from its hand-coded siblings. Only the body prose differs: it's this
// block's own `body` richText instead of hardcoded JSX.
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
@@ -64,74 +93,90 @@ function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: Pro
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} />
<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">
<ProductName name={product.name} />
</span>
</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-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)" }}>
<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>
<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>
{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>
{!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>
</Reveal>
<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 : []}
/>
<Reveal
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
delay={0.15}
>
<Image
src={product.image}
alt={product.name}
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
</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>
</section>
);
}
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
// file's own comment on why this closing panel looks the way it does.
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
@@ -139,7 +184,8 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P
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">
<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}
@@ -199,6 +245,7 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P
variants={product.active ? product.variants : []}
/>
</div>
</div>
</Reveal>
</section>
);
}
+3 -1
View File
@@ -859,7 +859,7 @@ export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
}));
}
export type TestimonialsPage = "todo-cards" | "newsletter" | "klarheits-check" | "der-eine";
export type TestimonialsPage = "todo-cards" | "newsletter" | "klarheits-check" | "der-eine" | "einfach-anfangen";
export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };
@@ -1346,6 +1346,7 @@ export type ProductBlock =
| { 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 };
type PayloadProductBlock =
@@ -1359,6 +1360,7 @@ type PayloadProductBlock =
| { 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 };
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {