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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user