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:
+96
-4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user