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
|
||||
|
||||
Reference in New Issue
Block a user