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