{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-card",
  "title": "Product Card",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Catalogue tile with badges, compare-at pricing, colour swatches that swap the image, rating and a keyboard-reachable quick-add. Compound: use it as one tag, or recompose it from ProductCardMedia, ProductCardBody and their parts via useProductCard().",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "@ecomcn/price-tag"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/product-card/product-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Plus, Star } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { PriceTag } from \"@/components/ecomcn/price-tag\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface ProductCardColor {\n  name: string\n  /** Any CSS colour, or a swatch image URL via `background`. */\n  hex: string\n  /** Shown in the media frame while this colour is selected. */\n  image?: React.ReactNode\n}\n\nexport interface ProductCardProduct {\n  id: string\n  name: string\n  href: string\n  brand?: string\n  price: number\n  compareAt?: number\n  /** Already-rendered image element, so this block never assumes next/image. */\n  image?: React.ReactNode\n  badge?: string\n  colors?: ProductCardColor[]\n  rating?: number\n  reviewCount?: number\n}\n\n/* ─── context ──────────────────────────────────────────────────────────────\n * Compound component: <ProductCard> holds the selected colour and the\n * quick-add state, and every part reads them through useProductCard(). That\n * is what lets a swatch change the image without either knowing the other.\n */\n\ntype QuickAddState = \"idle\" | \"pending\" | \"added\"\n\nexport interface ProductCardContextValue {\n  product: ProductCardProduct\n  currency: string\n  locale?: string\n  density: \"comfortable\" | \"compact\"\n  colorIndex: number\n  setColorIndex: (index: number) => void\n  /** The selected colour, if the product has any. */\n  color?: ProductCardColor\n  /** The selected colour's image, falling back to the product image. */\n  image: React.ReactNode\n  /** Undefined when the card was given no `onQuickAdd`. */\n  quickAdd?: () => void\n  quickAddState: QuickAddState\n}\n\nconst ProductCardContext = React.createContext<ProductCardContextValue | null>(null)\n\nexport function useProductCard() {\n  const context = React.useContext(ProductCardContext)\n  if (!context) {\n    throw new Error(\"useProductCard must be used within <ProductCard>.\")\n  }\n  return context\n}\n\n/** Controlled when `value` is passed, uncontrolled from `defaultValue` otherwise. */\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ProductCardProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onSelect\"> {\n  product: ProductCardProduct\n  currency?: string\n  locale?: string\n  density?: \"comfortable\" | \"compact\"\n  /** Return a promise to keep the button in its pending state until settled. */\n  onQuickAdd?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>\n  /** Controlled selected colour. */\n  colorIndex?: number\n  defaultColorIndex?: number\n  onColorChange?: (color: ProductCardColor, index: number) => void\n  /**\n   * Compose your own card from ProductCardMedia, ProductCardBody and their\n   * parts. Leave empty for the default layout.\n   */\n  children?: React.ReactNode\n}\n\nexport function ProductCard({\n  product,\n  currency = \"USD\",\n  locale,\n  density = \"comfortable\",\n  onQuickAdd,\n  colorIndex: colorIndexProp,\n  defaultColorIndex = 0,\n  onColorChange,\n  className,\n  children,\n  ...props\n}: ProductCardProps) {\n  const colors = product.colors\n  const handleColorChange = React.useCallback(\n    (index: number) => {\n      const color = colors?.[index]\n      if (color) onColorChange?.(color, index)\n    },\n    [colors, onColorChange]\n  )\n  const [colorIndex, setColorIndex] = useControllableState(\n    colorIndexProp,\n    defaultColorIndex,\n    handleColorChange\n  )\n\n  const [quickAddState, setQuickAddState] = React.useState<QuickAddState>(\"idle\")\n  const resetTimer = React.useRef<number | undefined>(undefined)\n  React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])\n\n  const quickAdd = React.useMemo(() => {\n    if (!onQuickAdd) return undefined\n    return async () => {\n      if (quickAddState === \"pending\") return\n      setQuickAddState(\"pending\")\n      try {\n        await onQuickAdd(product, colorIndex)\n        setQuickAddState(\"added\")\n        window.clearTimeout(resetTimer.current)\n        resetTimer.current = window.setTimeout(() => setQuickAddState(\"idle\"), 2000)\n      } catch {\n        setQuickAddState(\"idle\")\n      }\n    }\n  }, [onQuickAdd, quickAddState, product, colorIndex])\n\n  const color = product.colors?.[colorIndex]\n  const context = React.useMemo<ProductCardContextValue>(\n    () => ({\n      product,\n      currency,\n      locale,\n      density,\n      colorIndex,\n      setColorIndex,\n      color,\n      image: color?.image ?? product.image,\n      quickAdd,\n      quickAddState,\n    }),\n    [product, currency, locale, density, colorIndex, setColorIndex, color, quickAdd, quickAddState]\n  )\n\n  return (\n    <ProductCardContext.Provider value={context}>\n      <article\n        data-slot=\"product-card\"\n        className={cn(\"group relative flex flex-col\", className)}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <ProductCardMedia />\n            <ProductCardBody />\n          </>\n        )}\n      </article>\n    </ProductCardContext.Provider>\n  )\n}\n\n/* ─── media ────────────────────────────────────────────────────────────── */\n\n/** The 4:5 frame. Defaults to image, badge and quick-add; pass children to recompose. */\nexport function ProductCardMedia({\n  className,\n  children,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  useProductCard()\n  return (\n    <div\n      data-slot=\"product-card-media\"\n      className={cn(\"relative aspect-[4/5] overflow-hidden bg-secondary\", className)}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <ProductCardImage />\n          <ProductCardBadge />\n          <ProductCardQuickAdd />\n        </>\n      )}\n    </div>\n  )\n}\n\n/** Fills the media frame with the selected colour's image. */\nexport function ProductCardImage({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { image } = useProductCard()\n  return (\n    <div data-slot=\"product-card-image\" className={cn(\"absolute inset-0\", className)} {...props}>\n      {image}\n    </div>\n  )\n}\n\nexport function ProductCardBadge({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {\n  const { product } = useProductCard()\n  if (!product.badge) return null\n  return (\n    <span\n      data-slot=\"product-card-badge\"\n      className={cn(\n        \"absolute top-0 left-0 bg-foreground px-2.5 py-1.5 text-[11px] tracking-[0.16em] text-background uppercase\",\n        className\n      )}\n      {...props}\n    >\n      {product.badge}\n    </span>\n  )\n}\n\nexport function ProductCardQuickAdd({ className }: { className?: string }) {\n  const { quickAdd, quickAddState } = useProductCard()\n  if (!quickAdd) return null\n\n  return (\n    // focus-visible keeps this reachable without a pointer; the card link\n    // sits behind it via the ::after overlay on the title anchor.\n    <Button\n      type=\"button\"\n      data-slot=\"product-card-quick-add\"\n      onClick={quickAdd}\n      disabled={quickAddState === \"pending\"}\n      className={cn(\n        \"absolute inset-x-0 bottom-0 z-10 h-11 translate-y-full rounded-none text-[12px] tracking-[0.14em] transition-transform duration-200 motion-reduce:transition-none\",\n        \"group-hover:translate-y-0 focus-visible:translate-y-0\",\n        quickAddState === \"added\" &&\n          \"translate-y-0 bg-[var(--success)] hover:bg-[var(--success)]\",\n        className\n      )}\n    >\n      {quickAddState === \"added\" ? (\n        <>\n          <Check className=\"mr-1.5 size-3.5\" aria-hidden /> IN BAG\n        </>\n      ) : (\n        <>\n          <Plus className=\"mr-1.5 size-3.5\" aria-hidden />\n          {quickAddState === \"pending\" ? \"ADDING…\" : \"QUICK ADD\"}\n        </>\n      )}\n    </Button>\n  )\n}\n\n/* ─── body ─────────────────────────────────────────────────────────────── */\n\n/** The text column. Defaults to brand, title, price and meta; pass children to recompose. */\nexport function ProductCardBody({\n  className,\n  children,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  const { density } = useProductCard()\n  return (\n    <div\n      data-slot=\"product-card-body\"\n      className={cn(\"flex flex-1 flex-col\", density === \"compact\" ? \"pt-2.5\" : \"pt-3.5\", className)}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <ProductCardBrand />\n          <ProductCardTitle />\n          <ProductCardPrice />\n          <ProductCardMeta />\n        </>\n      )}\n    </div>\n  )\n}\n\nexport function ProductCardBrand({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {\n  const { product } = useProductCard()\n  if (!product.brand) return null\n  return (\n    <p\n      data-slot=\"product-card-brand\"\n      className={cn(\"text-[11px] tracking-[0.18em] text-muted-foreground uppercase\", className)}\n      {...props}\n    >\n      {product.brand}\n    </p>\n  )\n}\n\nexport function ProductCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {\n  const { product } = useProductCard()\n  return (\n    <h3 data-slot=\"product-card-title\" className={cn(\"mt-1 text-xl leading-tight\", className)} {...props}>\n      {/* One link target for the whole card via the ::after overlay — no\n          nested anchors. Anything interactive on top needs `relative z-10`. */}\n      <a href={product.href} className=\"after:absolute after:inset-0\">\n        {product.name}\n      </a>\n    </h3>\n  )\n}\n\nexport function ProductCardPrice({ className }: { className?: string }) {\n  const { product, currency, locale } = useProductCard()\n  return (\n    <PriceTag\n      price={product.price}\n      compareAt={product.compareAt}\n      currency={currency}\n      locale={locale}\n      size=\"sm\"\n      className={cn(\"mt-1.5\", className)}\n    />\n  )\n}\n\n/** Swatches and rating on one line. Hidden in the compact density. */\nexport function ProductCardMeta({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { product, density } = useProductCard()\n  if (density === \"compact\") return null\n  if (!children && !product.colors?.length && product.rating === undefined) return null\n  return (\n    <div\n      data-slot=\"product-card-meta\"\n      className={cn(\"mt-3 flex items-center justify-between gap-3\", className)}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <ProductCardSwatches />\n          <ProductCardRating />\n        </>\n      )}\n    </div>\n  )\n}\n\nexport function ProductCardSwatches({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { product, colorIndex, setColorIndex } = useProductCard()\n  if (!product.colors?.length) return <span />\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Colour\"\n      data-slot=\"product-card-swatches\"\n      className={cn(\"relative z-10 flex gap-1.5\", className)}\n      {...props}\n    >\n      {product.colors.map((color, i) => (\n        <button\n          key={color.name}\n          type=\"button\"\n          aria-label={color.name}\n          aria-pressed={i === colorIndex}\n          onClick={() => setColorIndex(i)}\n          className={cn(\n            \"size-4 border transition-all outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            i === colorIndex\n              ? \"border-foreground ring-1 ring-foreground ring-offset-2 ring-offset-background\"\n              : \"border-foreground/20 hover:border-foreground/50\"\n          )}\n          style={{ background: color.hex }}\n        />\n      ))}\n    </div>\n  )\n}\n\nexport function ProductCardRating({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {\n  const { product, locale } = useProductCard()\n  if (product.rating === undefined) return null\n  return (\n    <span\n      data-slot=\"product-card-rating\"\n      className={cn(\"flex items-center gap-1 text-xs text-muted-foreground tabular-nums\", className)}\n      {...props}\n    >\n      <Star className=\"size-3 fill-current\" aria-hidden />\n      <span className=\"sr-only\">Rated</span>\n      {new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(\n        product.rating\n      )}\n      {product.reviewCount ? (\n        <span className=\"opacity-60\">\n          ({new Intl.NumberFormat(locale).format(product.reviewCount)}\n          <span className=\"sr-only\"> reviews</span>)\n        </span>\n      ) : null}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/product-card.tsx"
    }
  ],
  "cssVars": {
    "light": {
      "sale": "0 72% 42%",
      "success": "138 30% 34%"
    },
    "dark": {
      "sale": "4 74% 58%",
      "success": "138 26% 52%"
    }
  },
  "categories": [
    "ecommerce",
    "product",
    "catalog"
  ],
  "type": "registry:block"
}