block
Storefront — Product
Gallery, variant picker driven by product options, price with compare-at, and add to cart.
npx shadcn@latest add @hextor/store-productSource
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Heart } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Price } from "@/registry/hextor/components/store/storefront/price";
import { ProductGrid } from "@/registry/hextor/components/store/storefront/product-grid";
import { QuantityStepper } from "@/registry/hextor/components/store/storefront/quantity-stepper";
import { VariantPicker } from "@/registry/hextor/components/store/storefront/variant-picker";
import { DeliveryEstimate } from "@/registry/hextor/components/store/storefront/delivery-estimate";
import {
OrderCostNote,
ProductDisclosures,
type ShippingEstimate,
} from "@/registry/hextor/components/store/storefront/product-disclosures";
import {
ProductDimensions,
type ProductDimension,
} from "@/registry/hextor/components/store/storefront/product-dimensions";
import {
ProductReviews,
type Review,
type ReviewsSummary,
} from "@/registry/hextor/components/store/storefront/product-reviews";
import { StickyBuyBar } from "@/registry/hextor/components/store/storefront/sticky-buy-bar";
import {
StoreHeader,
type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import {
StoreFooter,
type StoreFooterProps,
} from "@/registry/hextor/components/store/storefront/store-footer";
import { cn } from "@/registry/hextor/lib/utils";
import { isAvailable, type Product } from "@/registry/hextor/lib/store";
import {
resolveLabels,
type ProductLabels,
} from "@/registry/hextor/components/store/storefront/product-labels";
export interface ProductPageProps {
product: Product;
relatedProducts?: Product[];
header: StoreHeaderProps;
footer: StoreFooterProps;
onAddToCart?: (input: { productId: string; variantId: string; quantity: number }) => void;
labels?: Partial<ProductLabels>;
className?: string;
/** Save/wishlist (Baymard #798) — a plain toggle, never gated behind an account. */
saved?: boolean;
onToggleSave?: (saved: boolean) => void;
/**
* `now` and `leadTimeDays` together drive the delivery date. Both are
* required to render the estimate at all — there is no `new Date()`
* fallback here, on purpose (see `DeliveryEstimate`'s doc comment).
*/
now?: Date;
leadTimeDays?: number;
/** Lowest total order cost (Baymard #825) — omit to skip the note entirely. */
shippingEstimate?: ShippingEstimate;
/** Scale cue rendered beside the gallery (Baymard #741). */
dimensions?: ProductDimension[];
reviewsSummary?: ReviewsSummary;
reviews?: Review[];
locale?: string;
}
function defaultSelection(product: Product): Record<string, string> {
const firstVariant = product.variants[0];
return Object.fromEntries(
product.options.map((option) => [
option.name,
firstVariant?.selectedOptions.find((so) => so.name === option.name)?.value ?? option.values[0],
])
);
}
/**
* Gallery, variant picker, price and add-to-cart. The variant picker is
* driven by `product.options` rather than the flat variant list — see the
* doc comment on `ProductOption` in the domain model for why that axis has
* to stay separate from the selection.
*
* Information order follows Baymard's PDP guidance: hero media, title,
* price, variant selector, add to cart, then supporting detail (delivery,
* shipping/returns, description). Reviews and cross-sell sit below the fold,
* in that order — participants read reviews before browsing related items.
*/
export default function ProductPage({
product,
relatedProducts = [],
header,
footer,
onAddToCart,
labels,
className,
saved = false,
onToggleSave,
now,
leadTimeDays,
shippingEstimate,
dimensions = [],
reviewsSummary,
reviews = [],
locale,
}: ProductPageProps) {
const copy = resolveLabels(labels);
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() =>
defaultSelection(product)
);
const [quantity, setQuantity] = useState(1);
const [activeImageIndex, setActiveImageIndex] = useState(0);
const selectedVariant = useMemo(
() =>
product.variants.find((variant) =>
variant.selectedOptions.every((so) => selectedOptions[so.name] === so.value)
) ?? product.variants[0],
[product.variants, selectedOptions]
);
const images = product.images.length > 0 ? product.images : selectedVariant?.image ? [selectedVariant.image] : [];
const activeImage = images[Math.min(activeImageIndex, images.length - 1)];
const available = selectedVariant ? isAvailable(selectedVariant) : false;
const handleSelect = (optionName: string, value: string) => {
setSelectedOptions((prev) => ({ ...prev, [optionName]: value }));
};
const handleAddToCart = () => {
if (!selectedVariant) return;
onAddToCart?.({ productId: product.id, variantId: selectedVariant.id, quantity });
};
const maxQuantity =
selectedVariant && !selectedVariant.continueSellingWhenOutOfStock
? Math.max(selectedVariant.inventoryQuantity, 0)
: undefined;
// Sticky buy bar: visible once the primary add-to-cart button scrolls out
// of view, hidden again once the footer scrolls into view so it never sits
// on top of it. Two sentinels rather than one observer on the button alone
// — a PDP that ends in a short description would otherwise show the bar
// hovering over the footer for the entire rest of the scroll.
const ctaSentinelRef = useRef<HTMLDivElement>(null);
const footerSentinelRef = useRef<HTMLDivElement>(null);
const [ctaInView, setCtaInView] = useState(true);
const [footerInView, setFooterInView] = useState(false);
useEffect(() => {
const ctaEl = ctaSentinelRef.current;
const footerEl = footerSentinelRef.current;
if (!ctaEl || !footerEl || typeof IntersectionObserver === "undefined") return;
const ctaObserver = new IntersectionObserver(([entry]) => setCtaInView(entry.isIntersecting), {
threshold: 0,
});
const footerObserver = new IntersectionObserver(([entry]) => setFooterInView(entry.isIntersecting), {
threshold: 0,
});
ctaObserver.observe(ctaEl);
footerObserver.observe(footerEl);
return () => {
ctaObserver.disconnect();
footerObserver.disconnect();
};
}, []);
const stickyVisible = !ctaInView && !footerInView;
return (
<div className={cn("flex min-h-screen flex-col", className)}>
<StoreHeader {...header} />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-16 px-4 py-8 sm:px-6">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div className="flex flex-col gap-3">
<div className="relative aspect-square overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{activeImage && (
<Image
src={activeImage.url}
alt={activeImage.alt}
fill
priority
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
/>
)}
</div>
{images.length > 1 && (
<div className="flex gap-2">
{images.map((image, index) => (
<button
key={image.url + index}
type="button"
onClick={() => setActiveImageIndex(index)}
className={cn(
"relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted ring-1 ring-foreground/10",
index === activeImageIndex && "ring-2 ring-ring"
)}
>
<Image src={image.url} alt={image.alt} fill sizes="64px" className="object-cover" />
</button>
))}
</div>
)}
{dimensions.length > 0 && (
<ProductDimensions heading={copy.dimensionsHeading} dimensions={dimensions} />
)}
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
{product.vendor && <span className="text-sm text-muted-foreground">{product.vendor}</span>}
<h1 className="font-heading text-2xl font-semibold">{product.title}</h1>
{selectedVariant && (
<Price price={selectedVariant.price} compareAtPrice={selectedVariant.compareAtPrice} size="lg" locale={locale} />
)}
{shippingEstimate && selectedVariant && (
<OrderCostNote
price={selectedVariant.price}
shippingEstimate={shippingEstimate}
locale={locale}
freeShippingQualifiedLabel={copy.freeShippingQualified}
freeShippingThresholdLabel={copy.freeShippingThreshold}
shippingFromTotalLabel={copy.shippingFromTotal}
/>
)}
</div>
<Badge variant={available ? "secondary" : "outline"} className="w-fit">
{available ? copy.inStock : copy.outOfStock}
</Badge>
{product.options.length > 0 && (
<VariantPicker
options={product.options}
variants={product.variants}
selectedOptions={selectedOptions}
onSelect={handleSelect}
unavailableLabel={copy.outOfStock}
/>
)}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">{copy.quantityLabel}</span>
<QuantityStepper value={quantity} onChange={setQuantity} max={maxQuantity} />
</div>
<div ref={ctaSentinelRef} className="flex items-center gap-2">
<Button size="lg" disabled={!available} onClick={handleAddToCart} className="flex-1 sm:flex-none sm:w-auto">
{available ? copy.addToCart : copy.soldOut}
</Button>
{onToggleSave && (
<Button
type="button"
variant="outline"
size="icon-lg"
aria-pressed={saved}
aria-label={saved ? copy.saved : copy.save}
onClick={() => onToggleSave(!saved)}
>
<Heart className={cn(saved && "fill-destructive text-destructive")} />
</Button>
)}
</div>
{now && leadTimeDays != null && (
<DeliveryEstimate leadTimeDays={leadTimeDays} now={now} label={copy.deliveryEstimate} />
)}
<ProductDisclosures
shippingHeading={copy.shippingHeading}
shippingBody={copy.shippingBody}
returnsHeading={copy.returnsHeading}
returnsBody={copy.returnsBody}
/>
{product.description && (
<div className="flex flex-col gap-2 pt-2">
<span className="font-heading text-sm font-medium">{copy.descriptionHeading}</span>
<p className="text-sm whitespace-pre-line text-muted-foreground">{product.description}</p>
</div>
)}
{product.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{product.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
{reviewsSummary && (
<ProductReviews
summary={reviewsSummary}
reviews={reviews}
heading={copy.reviewsHeading}
countLabel={copy.reviewsCount}
emptyLabel={copy.reviewsEmpty}
merchantResponseLabel={copy.merchantResponseLabel}
ratingLabel={copy.ratingLabel}
imageLabel={copy.reviewImageLabel}
previousImageLabel={copy.previousImage}
nextImageLabel={copy.nextImage}
locale={locale}
/>
)}
{relatedProducts.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="font-heading text-xl font-medium">{copy.relatedHeading}</h2>
<ProductGrid products={relatedProducts} />
</section>
)}
</main>
<div ref={footerSentinelRef} aria-hidden="true" />
<StoreFooter {...footer} />
<StickyBuyBar
visible={stickyVisible}
title={product.title}
price={selectedVariant?.price}
compareAtPrice={selectedVariant?.compareAtPrice}
available={available}
onAddToCart={handleAddToCart}
addToCartLabel={copy.addToCart}
soldOutLabel={copy.soldOut}
locale={locale}
/>
</div>
);
}
Docs
The variant picker is driven by product.options rather than the flat variant list, so it can render a picker without inspecting every combination — and it disables the combinations that do not exist instead of letting someone pick their way into a 404.
onAddToCart is a callback, not a built-in store. A template that shipped its own cart state would be state you have to rip out.
Ships with sample data from @hextor/store-fixtures so it renders a real shop the moment it installs — a template you have to wire up before you can look at it is a template nobody evaluates. The route file is the only place that import appears.
Dependencies
@hextor/store-ui@hextor/store-fixtures