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
+2
View File
@@ -32,6 +32,8 @@ const product = (overrides: Partial<Product> = {}): Product => ({
noShippingCost: false,
categories: [],
relatedProduct: null,
usps: [],
badge: "none",
...overrides,
});
+96 -4
View File
@@ -275,6 +275,15 @@ export type Product = {
// to render this or its bespoke components, this type doesn't enforce
// either.
layout: ProductBlock[];
// Fixed PDP-shell fields, not part of `layout` — always render the same
// way regardless of block order, per the page-builder spec's "shell vs.
// blocks" split. Max 3 enforced server-side (Products.usps maxRows).
usps: { icon: string; text: string }[];
// "new" is the only manual state — discount/out-of-stock/low-stock
// badges are derived automatically elsewhere (discountPercent()/
// outOfStock/lowStock above); see Products.ts's own field comment for
// why those two aren't also manual options here.
badge: "none" | "new";
};
type PayloadProduct = {
@@ -316,6 +325,8 @@ type PayloadProduct = {
| null;
categories: ({ name: string } | number)[] | null;
relatedProduct: PayloadProduct | number | null;
usps: { icon: string; text: string }[] | null;
badge: "none" | "new" | null;
};
// A product/variant is only actually unbuyable when it opted into
@@ -407,6 +418,8 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
// 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),
usps: product.usps ?? [],
badge: product.badge ?? "none",
};
}
@@ -1354,7 +1367,24 @@ export type ProductBlock =
| { 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 };
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: string | null;
imagePosition: "left" | "right";
heading: string | null;
content: unknown;
bullets: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
// Not resolved to full Product objects here — same reasoning as
// testimonialsRef not resolving its testimonials in this mapper: needs
// its own async fetch (automatic mode queries by category; manual mode
// needs getProductsByIds), done in ProductBlocks.tsx's own outer
// Promise.all, mirroring how that file already special-cases
// testimonialsRef for the exact same reason.
| { blockType: "crossSell"; id: string; mode: "automatic" | "manual"; productIds: number[] };
type PayloadProductBlock =
| { blockType: "productHero"; id: string; body?: unknown | null }
@@ -1368,7 +1398,18 @@ type PayloadProductBlock =
| { 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 };
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: PayloadPageImageField;
imagePosition?: "left" | "right" | null;
heading?: string | null;
content?: unknown;
bullets?: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
| { blockType: "crossSell"; id: string; mode?: "automatic" | "manual" | null; products?: (number | { id: number })[] | null };
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
switch (block.blockType) {
@@ -1388,6 +1429,25 @@ function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
id: block.id,
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
};
case "storySplit":
return {
blockType: "storySplit",
id: block.id,
image: mapPayloadPageImage(block.image),
imagePosition: block.imagePosition ?? "left",
heading: block.heading ?? null,
content: block.content ?? null,
bullets: block.bullets ?? [],
};
case "faq":
return { blockType: "faq", id: block.id, items: block.items };
case "crossSell":
return {
blockType: "crossSell",
id: block.id,
mode: block.mode ?? "automatic",
productIds: (block.products ?? []).map((p) => (typeof p === "object" ? p.id : p)),
};
default:
return block;
}
@@ -1412,7 +1472,17 @@ export type PageBlock =
| { 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 };
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: string | null;
imagePosition: "left" | "right";
heading: string | null;
content: unknown;
bullets: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
export type Page = {
id: number;
@@ -1437,7 +1507,17 @@ type PayloadPageBlock =
| { 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 };
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
| {
blockType: "storySplit";
id: string;
image: PayloadPageImageField;
imagePosition?: "left" | "right" | null;
heading?: string | null;
content?: unknown;
bullets?: { id: string; text: string }[];
}
| { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };
export type PayloadPage = {
id: number;
@@ -1479,6 +1559,18 @@ function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
ctaLabel: block.ctaLabel ?? null,
ctaHref: block.ctaHref ?? null,
};
case "storySplit":
return {
blockType: "storySplit",
id: block.id,
image: mapPayloadPageImage(block.image),
imagePosition: block.imagePosition ?? "left",
heading: block.heading ?? null,
content: block.content ?? null,
bullets: block.bullets ?? [],
};
case "faq":
return { blockType: "faq", id: block.id, items: block.items };
default:
return block;
}