{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-card",
  "title": "Product Card",
  "description": "Borderless, image-forward product tile with hover image-swap and color swatches, for grids and carousels. Includes a loading skeleton.",
  "dependencies": [
    "@channel3/sdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "skeleton",
    "https://ui.trychannel3.com/r/format.json",
    "https://ui.trychannel3.com/r/variants.json"
  ],
  "files": [
    {
      "path": "registry/default/components/product-card.tsx",
      "content": "import * as React from \"react\";\nimport { ImageOff } from \"lucide-react\";\nimport type { OptionValue, Product } from \"@channel3/sdk/resources\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  formatCurrency,\n  formatPrice,\n  isOnSale,\n  isSoldOut,\n  leadOffer,\n  pickHoverImage,\n  pickImage,\n  productImageUrl,\n} from \"@/registry/default/lib/format\";\nimport { swatchOption } from \"@/registry/default/lib/variants\";\n\nconst MAX_SWATCHES = 10;\n\nexport interface ProductCardProps extends Omit<React.ComponentProps<\"div\">, \"onSelect\"> {\n  product: Product;\n  /**\n   * Destination URL for the card. When set, the image and title render as a real\n   * `<a href>` (crawlable, middle/cmd-clickable); a plain left-click still calls\n   * {@link ProductCardProps.onSelect} for SPA navigation.\n   */\n  href?: string;\n  onSelect?: (product: Product) => void;\n  /**\n   * Called when the card is hovered, focused, or touched — before activation.\n   * Router-agnostic prefetch hook: wire it to your framework's route preloader\n   * (e.g. TanStack Router `preloadRoute`, Next.js `router.prefetch`) so the\n   * destination is warm by the time the user clicks. Fires at most once per\n   * pointer entry/focus.\n   */\n  onPreload?: (product: Product) => void;\n  /**\n   * Called when a color swatch is clicked. Navigate to `value.product_id` (the\n   * variant's own product) when set. Falls back to {@link ProductCardProps.onSelect}.\n   */\n  onSelectVariant?: (value: OptionValue) => void;\n  showSwatches?: boolean;\n  priority?: boolean;\n  locale?: string;\n}\n\nexport function ProductCard({\n  product,\n  href,\n  onSelect,\n  onPreload,\n  onSelectVariant,\n  showSwatches = true,\n  priority = false,\n  locale,\n  className,\n  ...props\n}: ProductCardProps) {\n  const [imageFailed, setImageFailed] = React.useState(false);\n  const [imageLoaded, setImageLoaded] = React.useState(false);\n  const [preview, setPreview] = React.useState<string | null>(null);\n\n  // A server-rendered image can finish decoding before hydration, so `onLoad`\n  // never fires on the client — reveal it on mount if it's already complete.\n  const revealIfComplete = React.useCallback((node: HTMLImageElement | null) => {\n    if (node?.complete) {\n      setImageLoaded(true);\n    }\n  }, []);\n\n  const image = pickImage(product.images);\n  const imageSrc = image ? productImageUrl(image, { preferCleaned: true }) : null;\n  const secondImage = pickHoverImage(product.images, { excludeUrl: image?.url });\n  const brand = product.brands?.[0]?.name;\n  const offer = leadOffer(product.offers);\n  const soldOut = isSoldOut(product.offers);\n  const interactive = typeof onSelect === \"function\" || typeof onSelectVariant === \"function\";\n\n  const option = showSwatches && product.variants ? swatchOption(product.variants) : undefined;\n  const swatchValues = (option?.values ?? []).filter((value) => value.thumbnail_url);\n  const visibleSwatches = swatchValues.slice(0, MAX_SWATCHES);\n  const overflowSwatches = swatchValues.length - visibleSwatches.length;\n\n  const onSwatch = (value: OptionValue) => {\n    if (onSelectVariant) {\n      onSelectVariant(value);\n    } else {\n      onSelect?.(product);\n    }\n  };\n\n  const media = (\n    <div className=\"relative aspect-square overflow-hidden rounded-md bg-muted\">\n      {imageSrc && !imageFailed ? (\n        <img\n          src={imageSrc}\n          alt={image?.alt_text ?? \"\"}\n          loading={priority ? \"eager\" : \"lazy\"}\n          fetchPriority={priority ? \"high\" : undefined}\n          decoding=\"async\"\n          className={cn(\n            \"size-full object-cover transition duration-300\",\n            imageLoaded ? \"opacity-100\" : \"opacity-0\",\n            secondImage ? null : \"group-hover:scale-105\",\n          )}\n          ref={revealIfComplete}\n          onLoad={() => setImageLoaded(true)}\n          onError={() => setImageFailed(true)}\n        />\n      ) : (\n        <div className=\"flex size-full items-center justify-center text-muted-foreground\">\n          <ImageOff className=\"size-8\" aria-hidden />\n        </div>\n      )}\n      {secondImage && !imageFailed ? (\n        <img\n          src={secondImage.url}\n          alt=\"\"\n          loading={priority ? \"eager\" : \"lazy\"}\n          decoding=\"async\"\n          aria-hidden\n          className=\"absolute inset-0 size-full bg-muted object-cover opacity-0 transition-opacity duration-300 group-hover:opacity-100\"\n        />\n      ) : null}\n      {preview ? (\n        <img\n          src={preview}\n          alt=\"\"\n          aria-hidden\n          className=\"absolute inset-0 size-full bg-muted object-cover\"\n        />\n      ) : null}\n      {soldOut ? (\n        <Badge variant=\"secondary\" className=\"absolute right-2 top-2\">\n          Sold out\n        </Badge>\n      ) : null}\n    </div>\n  );\n\n  const fallbackThumb = imageSrc && !imageFailed ? imageSrc : null;\n  const thumbnails = showSwatches ? (\n    <div className=\"flex flex-wrap items-center gap-1 pt-2.5\">\n      {visibleSwatches.length > 0 ? (\n        <>\n          {visibleSwatches.map((value) => (\n            <button\n              key={value.label}\n              type=\"button\"\n              title={value.label}\n              aria-label={value.label}\n              onClick={() => onSwatch(value)}\n              onMouseEnter={() => setPreview(value.thumbnail_url ?? null)}\n              onMouseLeave={() => setPreview(null)}\n              onFocus={() => setPreview(value.thumbnail_url ?? null)}\n              onBlur={() => setPreview(null)}\n              className=\"size-5 cursor-pointer overflow-hidden rounded-full ring-1 ring-border transition hover:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <img src={value.thumbnail_url ?? undefined} alt=\"\" className=\"size-full object-cover\" />\n            </button>\n          ))}\n          {overflowSwatches > 0 ? (\n            <span className=\"text-xs text-muted-foreground\">{`+${overflowSwatches}`}</span>\n          ) : null}\n        </>\n      ) : (\n        <button\n          type=\"button\"\n          aria-label={product.title}\n          onClick={() => onSelect?.(product)}\n          className=\"flex size-5 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border transition hover:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          {fallbackThumb ? (\n            <img src={fallbackThumb} alt=\"\" className=\"size-full object-cover\" />\n          ) : (\n            <ImageOff className=\"size-2.5\" aria-hidden />\n          )}\n        </button>\n      )}\n    </div>\n  ) : null;\n\n  const meta = (\n    <div className=\"flex flex-col gap-1 pt-2.5\">\n      {brand ? <span className=\"truncate text-xs text-muted-foreground\">{brand}</span> : null}\n      <span className=\"truncate text-sm leading-snug font-medium\">{product.title}</span>\n      <div className=\"flex min-h-[1.5rem] items-baseline gap-2 pt-1\">\n        {offer ? (\n          <>\n            <span className=\"text-sm font-semibold\">{formatPrice(offer.price, locale)}</span>\n            {isOnSale(offer.price) && offer.price.compare_at_price ? (\n              <span className=\"text-xs text-muted-foreground line-through\">\n                {formatCurrency(offer.price.compare_at_price, offer.price.currency, locale)}\n              </span>\n            ) : null}\n          </>\n        ) : null}\n      </div>\n    </div>\n  );\n\n  const tapClass =\n    \"block w-full cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\n  const preload = onPreload ? () => onPreload(product) : undefined;\n\n  // Real href stays crawlable; a plain left-click is SPA via onSelect.\n  const tap = (children: React.ReactNode) => {\n    if (href) {\n      return (\n        <a\n          href={href}\n          className={tapClass}\n          onMouseEnter={preload}\n          onFocus={preload}\n          onTouchStart={preload}\n          onClick={(event) => {\n            if (\n              onSelect &&\n              event.button === 0 &&\n              !event.metaKey &&\n              !event.ctrlKey &&\n              !event.shiftKey &&\n              !event.altKey\n            ) {\n              event.preventDefault();\n              onSelect(product);\n            }\n          }}\n        >\n          {children}\n        </a>\n      );\n    }\n    if (interactive) {\n      return (\n        <button\n          type=\"button\"\n          onClick={() => onSelect?.(product)}\n          onMouseEnter={preload}\n          onFocus={preload}\n          onTouchStart={preload}\n          className={tapClass}\n        >\n          {children}\n        </button>\n      );\n    }\n    return children;\n  };\n\n  return (\n    <div data-slot=\"product-card\" className={cn(\"group flex h-full flex-col\", className)} {...props}>\n      {tap(media)}\n      {thumbnails}\n      {tap(meta)}\n    </div>\n  );\n}\n\nexport function ProductCardSkeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"product-card-skeleton\" className={cn(\"flex h-full flex-col\", className)} {...props}>\n      <Skeleton className=\"aspect-square w-full rounded-md\" />\n      <div className=\"flex flex-col gap-2 pt-3\">\n        <Skeleton className=\"h-3 w-1/3\" />\n        <Skeleton className=\"h-4 w-4/5\" />\n        <Skeleton className=\"mt-1 h-4 w-1/4\" />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}
