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 { 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