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
+34 -1
View File
@@ -22,6 +22,7 @@ export function AddToCartButton({
outOfStock = false,
maxQty = null,
variants = [],
showQuantityStepper = false,
}: {
label: string;
className?: string;
@@ -43,8 +44,14 @@ export function AddToCartButton({
* `variants` prop; all three callers already fetch the full product
* server-side, so this is just threaded straight through. */
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
/** Opt-in +/- quantity control before the button, adding `qty` at once
* instead of one click per unit. Off by default — every existing caller
* (grid cards, spotlight) keeps today's exact one-click-adds-one
* behavior; only the PDP hero/pricing panel enable this. */
showQuantityStepper?: boolean;
}) {
const [added, setAdded] = useState(false);
const [qty, setQty] = useState(1);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -56,14 +63,17 @@ export function AddToCartButton({
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
const remainingQty = currentMaxQty != null ? Math.max(0, currentMaxQty - qtyInCart) : null;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
const clampedQty = remainingQty != null ? Math.min(qty, Math.max(1, remainingQty)) : qty;
function handleClick() {
if (disabled) return;
addToCart(productId, 1, selectedVariant);
addToCart(productId, showQuantityStepper ? clampedQty : 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
setQty(1);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
}
@@ -119,6 +129,29 @@ export function AddToCartButton({
))}
</select>
)}
{showQuantityStepper && !currentlyOutOfStock && !limitReached && (
<div className="flex items-center gap-3 w-fit rounded-sm border border-border">
<button
type="button"
onClick={() => setQty((q) => Math.max(1, q - 1))}
disabled={qty <= 1}
aria-label="Menge verringern"
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
>
</button>
<span className="min-w-4 text-center text-body-sm text-text-primary tabular-nums">{clampedQty}</span>
<button
type="button"
onClick={() => setQty((q) => (remainingQty != null ? Math.min(remainingQty, q + 1) : q + 1))}
disabled={remainingQty != null && clampedQty >= remainingQty}
aria-label="Menge erhöhen"
className="flex items-center justify-center size-9 shrink-0 text-body font-bold text-text-primary disabled:opacity-40 hover:bg-bg-muted transition-colors"
>
+
</button>
</div>
)}
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button — same reasoning as
+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.
+124 -25
View File
@@ -1,16 +1,33 @@
import Link from "next/link";
import Image from "next/image";
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
import {
getShippingSettings,
getDefaultTaxRatePercent,
getKleinunternehmer,
getTestimonials,
getProducts,
getProductsByIds,
getWishlistEnabled,
} 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 { ProductGallery } from "./ProductGallery";
import { ProductCard } from "./ProductCard";
import { STEP_ICONS } from "./icons/StepIcons";
import { renderPageBlockSync } from "./PageBlocks";
import { TestimonialsGrid } from "./TestimonialsGrid";
// Same 3-recommendation width as cart's RelatedProducts.tsx grid, minus
// its cart-awareness (a PDP cross-sell doesn't need to exclude/reshuffle
// around what's already in the cart, that logic is specific to the cart
// page's own "here's what's missing" framing).
const CROSS_SELL_COUNT = 3;
// Renders a Payload Products document's `layout` blocks field — same
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
// this delegates to directly for every block type the two fields share
@@ -21,10 +38,11 @@ import { TestimonialsGrid } from "./TestimonialsGrid";
// need the live product/shipping/tax context that a generic content page
// doesn't have.
export async function ProductBlocks({ product }: { product: Product }) {
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
@@ -42,6 +60,34 @@ export async function ProductBlocks({ product }: { product: Product }) {
</div>
);
}
// Also needs its own async fetch — same reasoning as testimonialsRef
// just above. `manual` resolves the block's own picks by id;
// `automatic` picks active products sharing a category with this
// one (falling back to any other active product if it has none),
// excluding itself either way.
if (block.blockType === "crossSell") {
const recommended = await getCrossSellProducts(block, product);
if (recommended.length === 0) return null;
return (
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
<p className="font-semibold text-h-small text-text-primary mb-6" style={{ fontFamily: "var(--font-lora)" }}>
Das passt dazu
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{recommended.map((p) => (
<ProductCard
key={p.id}
product={p}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
/>
))}
</div>
</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
@@ -61,6 +107,22 @@ export async function ProductBlocks({ product }: { product: Product }) {
return <>{rendered}</>;
}
async function getCrossSellProducts(
block: Extract<ProductBlock, { blockType: "crossSell" }>,
current: Product
): Promise<Product[]> {
if (block.mode === "manual") {
if (block.productIds.length === 0) return [];
const picked = await getProductsByIds(block.productIds);
return picked.filter((p) => p.id !== current.id).slice(0, CROSS_SELL_COUNT);
}
const all = await getProducts();
const active = all.filter((p) => p.active && p.id !== current.id);
const sameCategory = current.categories.length > 0 ? active.filter((p) => p.categories.some((c) => current.categories.includes(c))) : [];
const pool = sameCategory.length > 0 ? sameCategory : active;
return pool.slice(0, CROSS_SELL_COUNT);
}
type ProductBlockContext = {
product: Product;
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
@@ -74,6 +136,22 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac
return <ProductHero {...ctx} body={block.body} />;
case "productPricingPanel":
return <ProductPricingPanel {...ctx} />;
// Needs an async fetch — handled by ProductBlocks' own outer
// Promise.all above, same as testimonialsRef. Never actually reached
// from there since both return their own JSX before calling this.
case "crossSell":
return null;
// A PDP-specific full-bleed treatment for this one block type only —
// not editing the shared Quote.tsx/renderPageBlockSync, which would
// also restyle every Pages.layout quote (e.g. /ueber-mich's).
case "quote":
return (
<div key={block.id} className="bg-brand rounded-xl px-6 py-10 sm:px-12 sm:py-14 text-center">
<p className="font-semibold text-h-small sm:text-h3 text-text-primary leading-snug" style={{ fontFamily: "var(--font-lora)" }}>
{block.text}
</p>
</div>
);
// Every other block type is identical to Pages.layout's own — same
// Block config, reused a second time (see this file's own comment).
default:
@@ -81,6 +159,29 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac
}
}
// Shared by ProductHero and ProductPricingPanel — previously only the
// pricing panel showed this (discount%/Ausverkauft), the hero showed
// nothing. Priority: out-of-stock, then discount, then the one manual
// state (Products.badge === "new") — never discount+badge at once, one
// pill only. Discount/Ausverkauft are derived, never editor-set, per
// Products.ts's own field comment on why "new" is the only manual option.
function ProductBadge({ product }: { product: Product }) {
const discount = discountPercent(product.price, product.compareAtPrice);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
if (fullyOutOfStock) {
return <span className="rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">Ausverkauft</span>;
}
if (discount !== null) {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">-{discount}%</span>;
}
if (product.badge === "new") {
return <span className="rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">Neu</span>;
}
return null;
}
// 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
@@ -124,6 +225,20 @@ function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: Pro
{body ? <RichText content={body} /> : null}
{product.usps.length > 0 && (
<ul className="flex flex-col gap-2 w-full">
{product.usps.map((usp, i) => {
const Icon = STEP_ICONS[usp.icon];
return (
<li key={i} className="flex items-center gap-3">
{Icon && <span className="flex items-center justify-center size-6 shrink-0 text-brand">{Icon()}</span>}
<p className="text-body-sm text-text-body">{usp.text}</p>
</li>
);
})}
</ul>
)}
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
@@ -153,22 +268,13 @@ function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: Pro
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
showQuantityStepper
/>
</div>
</Reveal>
<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 className="order-2 lg:order-none lg:col-span-7" delay={0.15}>
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} badge={<ProductBadge product={product} />} />
</Reveal>
</div>
</section>
@@ -194,17 +300,9 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
<div className="absolute top-3 left-3">
<ProductBadge product={product} />
</div>
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
@@ -243,6 +341,7 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P
outOfStock={fullyOutOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
showQuantityStepper
/>
</div>
</Reveal>
+17 -1
View File
@@ -15,7 +15,21 @@ const SWIPE_THRESHOLD_PX = 40;
* same "no charting/UI-library dependency for a simple case" reasoning as
* OrderQueueWidget.tsx's own OrderSparkline.
*/
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
export function ProductGallery({
image,
gallery,
alt,
badge,
}: {
image: string;
gallery: string[];
alt: string;
/** Optional overlay (e.g. a discount/Ausverkauft/Neu pill) absolutely
* positioned over the main image — top-3.5/left-3.5, same corner every
* other badge on the site anchors to. Not part of this component's own
* data; the caller decides what, if anything, to show. */
badge?: React.ReactNode;
}) {
const slides = [image, ...gallery];
const [current, setCurrent] = useState(0);
const touchStartX = useRef<number | null>(null);
@@ -24,6 +38,7 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery
return (
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
</div>
);
}
@@ -54,6 +69,7 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery
onTouchEnd={handleTouchEnd}
>
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
{badge && <div className="absolute top-3.5 left-3.5">{badge}</div>}
<button
type="button"
onClick={prev}