HextorUI
block

Storefront — Product

Gallery, variant picker driven by product options, price with compare-at, and add to cart.

npx shadcn@latest add @hextor/store-product

Source

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Check, 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 { ScrollRevealStyle } from "@/registry/hextor/components/store/storefront/scroll-reveal";
import { SectionHeading } from "@/registry/hextor/components/store/storefront/section-heading";
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 {
  defaultTrustItems,
  TrustStrip,
  type TrustStripItem,
} from "@/registry/hextor/components/store/storefront/trust-strip";
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";

/** How long the add-to-cart confirmation holds before reverting to the normal label — long enough to register as feedback, short enough that a shopper adding several variants back-to-back isn't stuck waiting on it. */
const ADDED_TO_CART_DURATION_MS = 1800;

export interface ProductPageProps {
  product: Product;
  relatedProducts?: Product[];
  /**
   * Builds the link for a product card. Defaults to `/products/{handle}`,
   * which is right for a store mounted at the root and wrong for one mounted
   * anywhere else — a demo under /demo, a shop under /shop, a locale prefix.
   * Threading it as a prop is what lets the same view serve all of them; the
   * alternative is rewriting hrefs in the DOM after render, which loses to a
   * click that lands before the script does.
   */
  productHrefFor?: (product: Product) => string;

  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[];

  /** The reassurance row under add-to-cart — returns, secure checkout, shipping, support. Defaults to modest, generically-true template copy; override with the store's real policies. */
  trustItems?: TrustStripItem[];

  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 = [],
  productHrefFor,
  header,
  footer,
  onAddToCart,
  labels,
  className,
  saved = false,
  onToggleSave,
  now,
  leadTimeDays,
  shippingEstimate,
  dimensions = [],
  reviewsSummary,
  reviews = [],
  trustItems = defaultTrustItems,
  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 }));
  };

  // Transient add-to-cart confirmation — purely local view state (not app
  // data), reset on every mount and cleared on unmount so a pending timeout
  // never fires setState after the component (or the product) is gone.
  const [justAdded, setJustAdded] = useState(false);
  const addedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    return () => {
      if (addedTimeoutRef.current) clearTimeout(addedTimeoutRef.current);
    };
  }, []);

  const handleAddToCart = () => {
    if (!selectedVariant) return;
    onAddToCart?.({ productId: product.id, variantId: selectedVariant.id, quantity });
    setJustAdded(true);
    if (addedTimeoutRef.current) clearTimeout(addedTimeoutRef.current);
    addedTimeoutRef.current = setTimeout(() => setJustAdded(false), ADDED_TO_CART_DURATION_MS);
  };

  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)}>
      <ScrollRevealStyle />
      <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 && (
                // Keying on the image itself forces a remount on every
                // gallery or variant-driven swap, which is what replays the
                // fade instead of the image just cutting from one frame to
                // the next.
                <div
                  key={`${activeImageIndex}-${activeImage.url}`}
                  className="absolute inset-0 animate-in fade-in duration-300 ease-out motion-reduce:animate-none"
                >
                  <Image
                    src={activeImage.url}
                    alt={activeImage.alt}
                    fill
                    priority
                    sizes="(min-width: 1024px) 50vw, 100vw"
                    className="object-cover"
                  />
                </div>
              )}
            </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 transition-shadow",
                      index === activeImageIndex && "ring-2 ring-brand-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.5">
              {product.vendor && (
                <span className="text-xs font-medium tracking-[0.14em] text-muted-foreground uppercase">
                  {product.vendor}
                </span>
              )}
              <h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
                {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={cn(
                  "flex-1 transition-colors sm:flex-none sm:w-auto",
                  available && "bg-brand text-brand-foreground hover:bg-brand/90"
                )}
              >
                {justAdded ? (
                  <span className="inline-flex items-center gap-1.5">
                    <Check
                      className="size-4 animate-in zoom-in-50 duration-200 motion-reduce:animate-none"
                      aria-hidden="true"
                    />
                    {copy.addedToCart}
                  </span>
                ) : 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(
                      "transition-transform duration-200 motion-reduce:transition-none",
                      saved && "scale-110 fill-destructive text-destructive"
                    )}
                  />
                </Button>
              )}
            </div>
            {/* Announces the confirmation to assistive tech the moment it
                happens — the visual swap on the button alone would otherwise
                be silent for anyone not looking directly at it. */}
            <span role="status" aria-live="polite" className="sr-only">
              {justAdded ? copy.addedToCart : ""}
            </span>

            <TrustStrip items={trustItems} className="border-t border-foreground/10 pt-4" />

            {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 && (
          <div className="reveal-up">
            <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}
            />
          </div>
        )}

        {relatedProducts.length > 0 && (
          <section className="flex flex-col gap-4">
            <SectionHeading title={copy.relatedHeading} />
            <ProductGrid products={relatedProducts} hrefFor={productHrefFor} />
          </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}
        justAdded={justAdded}
        addedLabel={copy.addedToCart}
        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