{
  "name": "product-card",
  "title": "ProductCard",
  "description": "Product display card with modal or link mode.",
  "type": "component",
  "registryDependencies": [
    "price",
    "product-sheet",
    "cn",
    "strip-html"
  ],
  "files": [
    {
      "path": "product-card.tsx",
      "content": "\"use client\";\n\nimport React, { useCallback, useContext, useState } from \"react\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { BOUNDARY_KIND, PRODUCT_TYPE, RENDER_HINT, DURATION_UNIT } from \"@cimplify/sdk\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { prefetchProduct, trackProductView } from \"./hooks/use-product\";\nimport { useInViewport } from \"./hooks/use-in-viewport\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { preloadImage } from \"./utils/preload-image\";\nimport {\n  useOptionalCimplifyClient,\n  DEFAULT_PREFETCH_OPTIONS,\n  type PrefetchMode,\n} from \"@cimplify/sdk/react\";\nimport { ImagePriorityContext } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { getProductCurrency } from \"@cimplify/sdk\";\nimport { ProductSheet } from \"@cimplify/sdk/react\";\nimport { CardVariant } from \"@cimplify/sdk/react\";\nimport type { CardLayoutProps } from \"@cimplify/sdk/react\";\nimport { FoodProductCard } from \"@cimplify/sdk/react\";\nimport { RetailProductCard } from \"@cimplify/sdk/react\";\nimport { WholesaleProductCard } from \"@cimplify/sdk/react\";\nimport { DigitalProductCard } from \"@cimplify/sdk/react\";\nimport { StandardServiceCard } from \"@cimplify/sdk/react\";\nimport { RentalServiceCard } from \"@cimplify/sdk/react\";\nimport { AccommodationCard } from \"@cimplify/sdk/react\";\nimport { LeaseServiceCard } from \"@cimplify/sdk/react\";\nimport { SubscriptionCard } from \"@cimplify/sdk/react\";\nimport { BundleProductCard } from \"./cards/bundle-product-card\";\nimport { CompositeProductCard } from \"./cards/composite-product-card\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { stripHtmlToText } from \"@cimplify/sdk/react\";\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n  square: { aspectRatio: \"1/1\" },\n  \"4/3\": { aspectRatio: \"4/3\" },\n  \"16/10\": { aspectRatio: \"16/10\" },\n  \"3/4\": { aspectRatio: \"3/4\" },\n};\n\nexport interface ProductCardClassNames {\n  root?: string;\n  imageContainer?: string;\n  image?: string;\n  body?: string;\n  name?: string;\n  description?: string;\n  price?: string;\n  badges?: string;\n  badge?: string;\n  modal?: string;\n  modalOverlay?: string;\n}\n\nexport interface ProductCardProps {\n  /** The product to display. */\n  product: Product;\n  /** Display mode: \"card\" opens a modal, \"page\" renders as a link. Auto-detected from product.display_mode. */\n  displayMode?: \"card\" | \"page\";\n  /** Explicit card variant. Auto-detected from product data when omitted. */\n  variant?: CardVariant;\n  /** Per-slug card map. Highest priority — maps a product slug to a custom card component. */\n  cards?: Record<string, React.ComponentType<CardLayoutProps>>;\n  /** Link href for page mode. Default: `/menu/${product.slug}` */\n  href?: string;\n  /** Custom modal content renderer. Receives the fully-loaded product and a close callback. */\n  renderModal?: (product: ProductWithDetails, onClose: () => void) => React.ReactNode;\n  /** Custom image renderer (e.g. Next.js Image). */\n  renderImage?: (props: {\n    src: string;\n    alt: string;\n    className?: string;\n  }) => React.ReactNode;\n  /** Custom link renderer for page mode (e.g. Next.js Link). */\n  renderLink?: (props: {\n    href: string;\n    className?: string;\n    children: React.ReactNode;\n    onPointerEnter?: React.PointerEventHandler<Element>;\n    onFocus?: React.FocusEventHandler<Element>;\n  }) => React.ReactElement;\n  /** Called when quick-add button is clicked (if the resolved card supports it). */\n  onQuickAdd?: (product: Product) => void;\n  /** Replace the entire default card body. */\n  children?: React.ReactNode;\n  /** Image aspect ratio. Default: \"4/3\". */\n  aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n  prefetch?: PrefetchMode;\n  className?: string;\n  classNames?: ProductCardClassNames;\n}\n\nconst BUILT_IN_CARDS: Record<string, React.ComponentType<CardLayoutProps>> = {\n  [CardVariant.Food]: FoodProductCard,\n  [CardVariant.Retail]: RetailProductCard,\n  [CardVariant.Wholesale]: WholesaleProductCard,\n  [CardVariant.Digital]: DigitalProductCard,\n  [CardVariant.Standard]: StandardServiceCard,\n  [CardVariant.Rental]: RentalServiceCard,\n  [CardVariant.Accommodation]: AccommodationCard,\n  [CardVariant.Lease]: LeaseServiceCard,\n  [CardVariant.Bundle]: BundleProductCard,\n  [CardVariant.Composite]: CompositeProductCard,\n  [CardVariant.Subscription]: SubscriptionCard,\n};\n\nconst RENTAL_UNITS = new Set<string>([DURATION_UNIT.Days, DURATION_UNIT.Hours]);\nconst LEASE_UNITS = new Set<string>([DURATION_UNIT.Weeks, DURATION_UNIT.Months, DURATION_UNIT.Years]);\n\nfunction resolveCardVariant(product: Product): CardVariant {\n  if (product.type === PRODUCT_TYPE.Bundle) {\n    return CardVariant.Bundle;\n  }\n  if (product.type === PRODUCT_TYPE.Composite) {\n    return CardVariant.Composite;\n  }\n  if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n    return CardVariant.Wholesale;\n  }\n  if (product.type === PRODUCT_TYPE.Digital) {\n    return CardVariant.Digital;\n  }\n  if (product.type === PRODUCT_TYPE.Service) {\n    // An explicit boundary_kind overrides the duration-unit heuristic for the\n    // card family (CTA + per-unit copy), so e.g. a days-unit serviced apartment\n    // marked check_in_out renders as accommodation, and a delivered rental as\n    // a rental. `custom`/unset fall through to the duration-unit defaults.\n    switch (product.boundary_kind) {\n      case BOUNDARY_KIND.CheckInOut:\n        return CardVariant.Accommodation;\n      case BOUNDARY_KIND.MoveInOut:\n        return CardVariant.Lease;\n      case BOUNDARY_KIND.PickupReturn:\n      case BOUNDARY_KIND.DeliveryCollection:\n      case BOUNDARY_KIND.AccessVacate:\n        return CardVariant.Rental;\n    }\n    if (product.duration_unit && RENTAL_UNITS.has(product.duration_unit)) {\n      return CardVariant.Rental;\n    }\n    if (product.duration_unit === DURATION_UNIT.Nights) {\n      return CardVariant.Accommodation;\n    }\n    if (product.duration_unit && LEASE_UNITS.has(product.duration_unit)) {\n      return CardVariant.Lease;\n    }\n    if (product.billing_plans && product.billing_plans.length > 0 && !product.duration_minutes) {\n      return CardVariant.Subscription;\n    }\n    return CardVariant.Standard;\n  }\n  if (product.render_hint === RENDER_HINT.Food) {\n    return CardVariant.Food;\n  }\n  if (product.render_hint === RENDER_HINT.Physical) {\n    return CardVariant.Retail;\n  }\n  return CardVariant.Retail;\n}\n\n/**\n * ProductCard — a product display card with two modes:\n *\n * - **card** (default): clickable button that opens a Base UI Dialog modal\n * - **page**: a plain `<a>` link for SEO-friendly product pages\n */\nexport function ProductCard({\n  product,\n  displayMode,\n  variant,\n  cards,\n  href,\n  renderModal,\n  renderImage,\n  renderLink,\n  onQuickAdd,\n  children,\n  aspectRatio = \"4/3\",\n  prefetch: prefetchProp,\n  className,\n  classNames,\n}: ProductCardProps): React.ReactElement {\n  const mode = displayMode ?? product.display_mode ?? \"card\";\n  const [isOpen, setIsOpen] = useState(false);\n\n  const context = useOptionalCimplifyClient();\n  const client = context?.client ?? null;\n  const prefetchConfig = context?.prefetch ?? DEFAULT_PREFETCH_OPTIONS;\n  const LinkComponent = context?.linkComponent ?? null;\n  const prefetchMode = prefetchProp ?? prefetchConfig.mode;\n  const imagePriority = useContext(ImagePriorityContext);\n\n  const { product: productDetails } = useProduct(product.slug ?? product.id, {\n    enabled: isOpen,\n  });\n\n  const warm = useCallback(() => {\n    if (!client || prefetchMode === \"off\") return;\n    speculate(() => prefetchProduct(client, product.slug ?? product.id));\n    if (prefetchConfig.images) {\n      preloadImage(product.images?.[0] || product.image_url);\n    }\n  }, [client, prefetchMode, prefetchConfig.images, product]);\n\n  const viewportRef = useInViewport(warm, {\n    rootMargin: prefetchConfig.rootMargin,\n    disabled: prefetchMode !== \"aggressive\",\n  });\n\n  const handleOpenChange = useCallback(\n    (open: boolean) => {\n      setIsOpen(open);\n      if (open && client) {\n        trackProductView(client, product);\n      }\n    },\n    [client, product],\n  );\n\n  const handleClose = useCallback(() => {\n    setIsOpen(false);\n  }, []);\n\n  const imageUrl = product.image_url || product.images?.[0];\n\n  const cardBody = (() => {\n    if (children) return children;\n\n    // Resolve card variant: slug map → explicit variant → auto-detect\n    const SlugCard = cards?.[product.slug];\n    if (SlugCard) {\n      return <SlugCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n    }\n\n    const key = variant ?? resolveCardVariant(product);\n    const ResolvedCard = BUILT_IN_CARDS[key];\n    if (ResolvedCard) {\n      return <ResolvedCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n    }\n\n    // Fallback: minimal default\n    const descriptionPreview = stripHtmlToText(product.description);\n    return (\n      <>\n        {imageUrl && (\n          <div\n            data-cimplify-product-card-image-container\n            className={cn(\"overflow-hidden rounded-t-xl\", classNames?.imageContainer)}\n            style={ASPECT_STYLES[aspectRatio]}\n          >\n            {renderImage ? (\n              renderImage({ src: imageUrl, alt: product.name, className: classNames?.image })\n            ) : (\n              <img\n                src={imageUrl}\n                alt={product.name}\n                loading={imagePriority ? \"eager\" : \"lazy\"}\n                decoding={imagePriority ? \"auto\" : \"async\"}\n                fetchPriority={imagePriority ? \"high\" : undefined}\n                className={cn(\"w-full h-full object-cover transition-transform duration-300 group-hover:scale-105\", classNames?.image)}\n                data-cimplify-product-card-image\n              />\n            )}\n          </div>\n        )}\n        <div data-cimplify-product-card-body className={cn(\"p-4 flex flex-col gap-1\", classNames?.body)}>\n          <span data-cimplify-product-card-name className={cn(\"font-semibold text-foreground leading-tight\", classNames?.name)}>\n            {product.name}\n          </span>\n          {descriptionPreview && (\n            <span data-cimplify-product-card-description className={cn(\"text-sm text-muted-foreground line-clamp-2\", classNames?.description)}>\n              {descriptionPreview}\n            </span>\n          )}\n          <Price amount={product.default_price} currency={getProductCurrency(product)} className={cn(\"text-sm font-medium text-foreground mt-1\", classNames?.price)} />\n        </div>\n      </>\n    );\n  })();\n\n  // Page mode — render as a link\n  if (mode === \"page\") {\n    const linkHref = href ?? `/menu/${product.slug}`;\n    const linkClassName = cn(\"group block no-underline text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md\", className, classNames?.root);\n\n    if (renderLink) {\n      return renderLink({\n        href: linkHref,\n        className: linkClassName,\n        children: cardBody,\n        onPointerEnter: warm,\n        onFocus: warm,\n      });\n    }\n\n    if (LinkComponent) {\n      return (\n        <LinkComponent\n          href={linkHref}\n          className={linkClassName}\n          onPointerEnter={warm}\n          onFocus={warm}\n        >\n          {cardBody}\n        </LinkComponent>\n      );\n    }\n\n    return (\n      <a\n        href={linkHref}\n        ref={viewportRef}\n        onPointerEnter={warm}\n        onFocus={warm}\n        data-cimplify-product-card\n        data-display-mode=\"page\"\n        className={linkClassName}\n      >\n        {cardBody}\n      </a>\n    );\n  }\n\n  // Card mode — render as Base UI Dialog\n  return (\n    <Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>\n      <Dialog.Trigger\n        ref={viewportRef}\n        onPointerEnter={warm}\n        onFocus={warm}\n        data-cimplify-product-card\n        data-display-mode=\"card\"\n        className={cn(\n          \"group block w-full text-left p-0 cursor-pointer font-[inherit] text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md\",\n          className,\n          classNames?.root,\n        )}\n      >\n        {cardBody}\n      </Dialog.Trigger>\n\n      <Dialog.Portal>\n        <Dialog.Backdrop\n          data-cimplify-product-card-backdrop\n          className={cn(\n            \"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity\",\n            classNames?.modalOverlay,\n          )}\n        />\n        <Dialog.Popup\n          data-cimplify-product-card-modal\n          className={cn(\n            \"fixed z-50 rounded-2xl p-0 max-w-lg w-full h-fit max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none\",\n            classNames?.modal,\n          )}\n        >\n          {isOpen && (\n            productDetails ? (\n              renderModal ? (\n                renderModal(productDetails, handleClose)\n              ) : (\n                <ProductSheet\n                  product={productDetails}\n                  onClose={handleClose}\n                  renderImage={renderImage}\n                />\n              )\n            ) : (\n              <div\n                data-cimplify-product-card-modal-loading\n                aria-busy=\"true\"\n                className=\"flex flex-col\"\n              >\n                <div className=\"aspect-[5/2] bg-muted animate-pulse\" />\n                <div className=\"p-6 space-y-3\">\n                  <div className=\"h-5 w-3/5 bg-muted rounded animate-pulse\" />\n                  <div className=\"h-7 w-2/5 bg-muted rounded animate-pulse\" />\n                  <div className=\"h-4 w-4/5 bg-muted rounded animate-pulse\" />\n                </div>\n                <div className=\"mx-6 border-t border-border pt-4 pb-6\">\n                  <div className=\"h-14 bg-muted rounded-full animate-pulse\" />\n                </div>\n              </div>\n            )\n          )}\n        </Dialog.Popup>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n"
    }
  ]
}
