Frontend: render storySplit/faq/crossSell, gallery+badge+usps, qty stepper

Wires up the new PDP page-builder gaps (Products.layout backend already
built, migrated in a companion payload repo commit):

- storySplit/faq render via PageBlocks.tsx (shared with Pages.layout);
  crossSell is Products-only, resolved server-side in ProductBlocks.tsx
  (manual: getProductsByIds; automatic: active products sharing a
  category, falling back to any other active product).
- ProductGallery gets an optional `badge` overlay slot; ProductHero now
  uses it (previously a single plain <Image>, no badge at all) instead
  of duplicating a second image element.
- New shared ProductBadge helper (discount % / Ausverkauft / Neu) used
  by both ProductHero and ProductPricingPanel — only the pricing panel
  showed this before.
- product.usps (max-3 icon+text) renders in the hero, reusing
  StepRowBlock's icon set via STEP_ICONS.
- AddToCartButton gets an opt-in showQuantityStepper prop (default off,
  no behavior change for existing callers); enabled on both PDP
  buy buttons.
- quote gets a PDP-specific full-bleed brand-background treatment,
  scoped to ProductBlocks.tsx's own switch case so Pages.layout's
  existing quote styling (e.g. /ueber-mich) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
This commit is contained in:
Marco
2026-08-29 22:58:42 +00:00
parent 43bffaa5c1
commit 74bec632ca
6 changed files with 329 additions and 31 deletions
+56
View File
@@ -209,6 +209,62 @@ export function renderPageBlockSync(block: PageBlock): React.ReactNode {
</Link>
);
case "storySplit":
return (
<div
key={block.id}
className={`flex flex-col gap-8 lg:gap-10 items-center w-full ${block.imagePosition === "right" ? "lg:flex-row-reverse" : "lg:flex-row"}`}
>
{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>
)}
<div className="flex-1 w-full flex flex-col gap-4">
{block.heading && (
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
{block.heading}
</p>
)}
{block.content ? <RichText content={block.content} /> : null}
{block.bullets.length > 0 && (
<ul className="flex flex-col gap-2">
{block.bullets.map((bullet) => (
<li key={bullet.id} className="flex items-start gap-3">
<CheckIcon />
<p className="text-body text-text-body">{bullet.text}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
case "faq":
return (
<div key={block.id} className="flex flex-col gap-3 w-full">
{block.items.map((item) => (
<details key={item.id} className="group border border-border rounded-md px-5 py-4 open:border-brand transition-colors">
<summary className="flex items-center justify-between gap-4 cursor-pointer list-none font-semibold text-body text-text-primary">
{item.question}
<svg
viewBox="0 0 20 20"
className="size-4 shrink-0 text-text-muted transition-transform duration-200 group-open:rotate-45"
fill="none"
aria-hidden="true"
>
<path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</summary>
<div className="pt-3 text-body-sm text-text-muted">
<RichText content={item.answer} />
</div>
</details>
))}
</div>
);
default:
// Unknown/malformed block — same "skip rather than crash" convention
// RichText.tsx's own blocks use.