HextorUI
block

Storefront components

The shared storefront library: header, footer, product card and grid, variant picker, price, cart line, order summary, filters.

npx shadcn@latest add @hextor/store-ui

Source

import { formatMoney, type Money } from "@/registry/hextor/lib/store";
import { cn } from "@/registry/hextor/lib/utils";

/**
 * Renders a single price, a sale price with its struck-through compare-at, or
 * a "from" range for a product whose variants are not all the same price.
 *
 * `range` wins over `price` when both are given rather than erroring, so a
 * caller can pass `priceRange(product)` unconditionally and let this decide
 * whether the range collapses to a single number — every variant of a
 * single-priced product has `min.amount === max.amount`, and re-deriving that
 * check in every caller is the kind of duplication that drifts.
 */
export function Price({
  price,
  compareAtPrice,
  range,
  locale,
  size = "default",
  fromLabel = "From",
  className,
}: {
  price?: Money;
  compareAtPrice?: Money;
  range?: { min: Money; max: Money };
  locale?: string;
  size?: "sm" | "default" | "lg";
  /** Prefix shown before a collapsed range, e.g. "From ฿590". */
  fromLabel?: string;
  className?: string;
}) {
  const sizeClass = {
    sm: "text-sm",
    default: "text-base",
    lg: "text-2xl",
  }[size];

  if (range) {
    const isRange = range.min.amount !== range.max.amount;
    return (
      <span className={cn("inline-flex items-baseline gap-1 font-medium tabular-nums", sizeClass, className)}>
        {isRange && <span className="text-muted-foreground">{fromLabel}</span>}
        {formatMoney(range.min, locale)}
      </span>
    );
  }

  if (!price) return null;

  const onSale = compareAtPrice != null && compareAtPrice.amount > price.amount;

  return (
    <span className={cn("inline-flex items-baseline gap-1.5 font-medium tabular-nums", sizeClass, className)}>
      <span className={cn(onSale && "text-destructive")}>{formatMoney(price, locale)}</span>
      {onSale && compareAtPrice && (
        <span className="text-muted-foreground line-through">{formatMoney(compareAtPrice, locale)}</span>
      )}
    </span>
  );
}

Docs

Every storefront page composes these; installing any page pulls them in once.

The surface was measured against Baymard Institute's 2026 benchmarks rather than taste, which is why several pieces exist at all. VariantPicker is buttons, not a dropdown (57% of leading sites still hide sizes behind a select, so shoppers discover an out-of-stock option only after choosing it). QuantityStepper is plus/minus, not a text field (97% get this wrong; typing turns "1" then "2" into 12). DeliveryOptions and DeliveryEstimate quote a DATE and a cutoff countdown, never "3-5 business days" (48% show only speeds; users cannot convert business days across weekends and time zones). FieldGroup marks required AND optional fields, because when only the required ones are marked 32% of participants skip them anyway. checkout-validation.ts says what is actually wrong -- "Card number is incomplete", not "invalid" -- which is the single worst-performing guideline in the benchmark at 94% non-compliance.

Every component that reads a clock takes now as a required prop. new Date() in render makes the server and the client disagree and breaks hydration; the countdown advances from an effect after mount so the first paint still matches. StoreHeader takes activeHref as a prop rather than calling usePathname() — a block that reaches for a router hook only works inside the router it was authored against.

Price handles the three cases that get missed: a single price, a struck-through compare-at, and a range that collapses to a bare price when min equals max.

Dependencies

lucide-reactbuttonbadgecardseparatorinputlabelselectcheckboxradio-groupaccordiondialog@hextor/store-model