diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index c87ae21..c8b165e 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -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 | undefined>(undefined); const buttonRef = useRef(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({ ))} )} + {showQuantityStepper && !currentlyOutOfStock && !limitReached && ( +
+ + {clampedQty} + +
+ )} {currentlyOutOfStock ? ( // Replaces the button slot entirely rather than stacking below a // disabled "Ausverkauft" button — same reasoning as diff --git a/app/components/PageBlocks.tsx b/app/components/PageBlocks.tsx index f5d5686..d67d6cf 100644 --- a/app/components/PageBlocks.tsx +++ b/app/components/PageBlocks.tsx @@ -209,6 +209,62 @@ export function renderPageBlockSync(block: PageBlock): React.ReactNode { ); + case "storySplit": + return ( +
+ {block.image && ( +
+ +
+ )} +
+ {block.heading && ( +

+ {block.heading} +

+ )} + {block.content ? : null} + {block.bullets.length > 0 && ( +
    + {block.bullets.map((bullet) => ( +
  • + +

    {bullet.text}

    +
  • + ))} +
+ )} +
+
+ ); + + case "faq": + return ( +
+ {block.items.map((item) => ( +
+ + {item.question} + + +
+ +
+
+ ))} +
+ ); + default: // Unknown/malformed block — same "skip rather than crash" convention // RichText.tsx's own blocks use. diff --git a/app/components/ProductBlocks.tsx b/app/components/ProductBlocks.tsx index c6fe618..8c4860e 100644 --- a/app/components/ProductBlocks.tsx +++ b/app/components/ProductBlocks.tsx @@ -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 }) { ); } + // 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 ( +
+

+ Das passt dazu +

+
+ {recommended.map((p) => ( + + ))} +
+
+ ); + } // 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, + current: Product +): Promise { + 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>; @@ -74,6 +136,22 @@ function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): Reac return ; case "productPricingPanel": return ; + // 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 ( +
+

+ {block.text} +

+
+ ); // 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 Ausverkauft; + } + if (discount !== null) { + return -{discount}%; + } + if (product.badge === "new") { + return Neu; + } + 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 ? : null} + {product.usps.length > 0 && ( +
    + {product.usps.map((usp, i) => { + const Icon = STEP_ICONS[usp.icon]; + return ( +
  • + {Icon && {Icon()}} +

    {usp.text}

    +
  • + ); + })} +
+ )} +
{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 />
- - {product.name} + + } />
@@ -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 ? ( - - Ausverkauft - - ) : ( - discount !== null && ( - - -{discount}% - - ) - )} +
+ +
@@ -243,6 +341,7 @@ function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: P outOfStock={fullyOutOfStock} maxQty={product.maxQty} variants={product.active ? product.variants : []} + showQuantityStepper />
diff --git a/app/components/ProductGallery.tsx b/app/components/ProductGallery.tsx index 227807e..9092dde 100644 --- a/app/components/ProductGallery.tsx +++ b/app/components/ProductGallery.tsx @@ -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(null); @@ -24,6 +38,7 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery return (
{alt} + {badge &&
{badge}
}
); } @@ -54,6 +69,7 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery onTouchEnd={handleTouchEnd} > {alt} + {badge &&
{badge}
}