From e882b8b6ac562c8bfa7e293e2bf4303b8bc038cc Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 26 Aug 2026 23:24:14 +0000 Subject: [PATCH] Match block-driven PDP hero/pricing panel to hand-coded siblings visually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt --- app/components/ProductBlocks.tsx | 185 +++++++++++++++++++------------ app/lib/payload.ts | 4 +- 2 files changed, 119 insertions(+), 70 deletions(-) diff --git a/app/components/ProductBlocks.tsx b/app/components/ProductBlocks.tsx index 015fa5e..c6fe618 100644 --- a/app/components/ProductBlocks.tsx +++ b/app/components/ProductBlocks.tsx @@ -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) => ( -
+ 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 ( +
+ +
+ ); + } + // 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
{renderProductBlock(block, ctx)}
; + } + return ( + {renderProductBlock(block, ctx)} -
- ))} - + + ); + }) ); + + 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 ( -
-
-

- - Startseite - - - - Werkzeuge - - - - - -

- -
-

- +

+
+ +

+ + Startseite + + + + Werkzeuge + + + + +

- {product.subline && ( -

- {product.subline} -

- )} -
- {body ? : null} +
+
+

+ +

+ {product.subline && ( +

+ {product.subline} +

+ )} +
-
-
- {discount !== null && ( -

{formatPrice(product.compareAtPrice!)}

- )} -

{formatPrice(product.price)}

-

- {kleinunternehmer - ? product.noShippingCost - ? "Keine Versandkosten" - : "zzgl. Versand" - : `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`} -

+ {body ? : null} + +
+
+ {discount !== null && ( +

{formatPrice(product.compareAtPrice!)}

+ )} +

{formatPrice(product.price)}

+

+ {kleinunternehmer + ? product.noShippingCost + ? "Keine Versandkosten" + : "zzgl. Versand" + : `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`} +

+
+ {!product.noShippingCost && ( +

+ Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands +

+ )} + {anyLowStock &&

Nur noch wenige verfügbar

} +
+ +
- {!product.noShippingCost && ( -

- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands -

- )} - {anyLowStock &&

Nur noch wenige verfügbar

} -
+ - + + {product.name} +
- -
- {product.name} -
-
+
); } +// 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 ( -
+
+
-
+ + ); } diff --git a/app/lib/payload.ts b/app/lib/payload.ts index b9f8981..4442ad3 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -859,7 +859,7 @@ export async function getWerkzeugeCards(): Promise { })); } -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 {