@cimplify/sdk 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/advanced.d.mts +1 -1
  2. package/dist/advanced.d.ts +1 -1
  3. package/dist/{chunk-FURKCWVG.mjs → chunk-FIC5S3KD.mjs} +1 -1
  4. package/dist/{chunk-T7ALPOK7.js → chunk-KMP4HVA2.js} +2 -2
  5. package/dist/{chunk-LBFTEXGD.mjs → chunk-NJGJCQWF.mjs} +9 -0
  6. package/dist/{chunk-5BPBDWAA.js → chunk-NV445GMS.js} +9 -0
  7. package/dist/{client-Byysg2IX.d.mts → client-6BNd11sq.d.mts} +1 -1
  8. package/dist/{client-BAzvXSLJ.d.ts → client-CuoWlAfH.d.ts} +1 -1
  9. package/dist/{client-D16qfHAB.d.mts → client-Czbt-mej.d.mts} +3 -0
  10. package/dist/{client-Cn5trGYV.d.ts → client-D22iSqke.d.ts} +3 -0
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +10 -10
  14. package/dist/index.mjs +1 -1
  15. package/dist/react.d.mts +33 -4
  16. package/dist/react.d.ts +33 -4
  17. package/dist/react.js +332 -51
  18. package/dist/react.mjs +312 -52
  19. package/dist/server.d.mts +2 -2
  20. package/dist/server.d.ts +2 -2
  21. package/dist/server.js +2 -2
  22. package/dist/server.mjs +1 -1
  23. package/dist/testing/suite.d.mts +2 -2
  24. package/dist/testing/suite.d.ts +2 -2
  25. package/dist/testing/suite.js +21 -21
  26. package/dist/testing/suite.mjs +2 -2
  27. package/dist/testing.d.mts +2 -2
  28. package/dist/testing.d.ts +2 -2
  29. package/dist/testing.js +77 -77
  30. package/dist/testing.mjs +3 -3
  31. package/package.json +7 -2
  32. package/registry/catalogue-page.json +1 -1
  33. package/registry/product-card.json +1 -1
  34. package/registry/product-grid.json +1 -1
  35. package/registry/product-image-gallery.json +1 -1
  36. package/registry/product-page.json +1 -1
  37. package/registry/recently-viewed.json +1 -1
  38. package/registry/recommendation-carousel.json +1 -1
  39. package/registry/search-input.json +1 -1
  40. package/dist/server-BXT62nBC.d.mts +0 -1186
  41. package/dist/server-BXT62nBC.d.ts +0 -1186
@@ -12,7 +12,7 @@
12
12
  "files": [
13
13
  {
14
14
  "path": "product-card.tsx",
15
- "content": "\"use client\";\n\nimport React, { useCallback, useState } from \"react\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { PRODUCT_TYPE, RENDER_HINT, DURATION_UNIT } from \"@cimplify/sdk\";\nimport { useProduct } 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 }) => 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 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 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 className,\n classNames,\n}: ProductCardProps): React.ReactElement {\n const mode = displayMode ?? product.display_mode ?? \"card\";\n const [isOpen, setIsOpen] = useState(false);\n const [shouldFetch, setShouldFetch] = useState(false);\n\n // Prefetch on pointer enter, always fetch when open\n const { product: productDetails } = useProduct(\n product.slug ?? product.id,\n { enabled: shouldFetch || isOpen },\n );\n\n const handlePrefetch = useCallback(() => {\n setShouldFetch(true);\n }, []);\n\n const handleOpenChange = useCallback((open: boolean) => {\n setIsOpen(open);\n if (open) {\n setShouldFetch(true);\n }\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 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({ href: linkHref, className: linkClassName, children: cardBody });\n }\n\n return (\n <a\n href={linkHref}\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 onPointerEnter={handlePrefetch}\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"
15
+ "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 { 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 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"
16
16
  }
17
17
  ]
18
18
  }
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "product-grid.tsx",
13
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(\"\"),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No products found\"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n"
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { ImagePriorityContext } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(\"\"),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No products found\"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product, index) => {\n const card = renderCard ? (\n renderCard(product)\n ) : (\n <ProductCard product={product} renderImage={renderImage} />\n );\n return (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {index < xl ? (\n <ImagePriorityContext.Provider value={true}>{card}</ImagePriorityContext.Provider>\n ) : (\n card\n )}\n </div>\n );\n })}\n </div>\n </>\n );\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -7,7 +7,7 @@
7
7
  "files": [
8
8
  {
9
9
  "path": "product-image-gallery.tsx",
10
- "content": "\"use client\";\n\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\n\nexport interface ProductImageGalleryProps {\n images: string[];\n productName: string;\n aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n className?: string;\n}\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\n/**\n * ProductImageGallery — main image + thumbnail strip.\n *\n * Uses plain `<img>` for framework-agnostic rendering (not Next.js Image).\n */\nexport function ProductImageGallery({\n images,\n productName,\n aspectRatio = \"4/3\",\n className,\n}: ProductImageGalleryProps): React.ReactElement | null {\n const normalizedImages = useMemo(\n () =>\n images.filter(\n (image): image is string =>\n typeof image === \"string\" && image.trim().length > 0,\n ),\n [images],\n );\n\n const [selectedImage, setSelectedImage] = useState(0);\n const [fadeIn, setFadeIn] = useState(true);\n\n useEffect(() => {\n setSelectedImage(0);\n }, [normalizedImages.length, productName]);\n\n // Cross-fade the main image whenever the selection changes (thumbnail click\n // or a variant swapping in its own gallery).\n useEffect(() => {\n setFadeIn(false);\n const id = requestAnimationFrame(() => setFadeIn(true));\n return () => cancelAnimationFrame(id);\n }, [selectedImage]);\n\n if (normalizedImages.length === 0) {\n return null;\n }\n\n const activeImage = normalizedImages[selectedImage] || normalizedImages[0];\n\n return (\n <div data-cimplify-image-gallery className={className}>\n <div\n data-cimplify-image-gallery-main\n style={{ position: \"relative\", overflow: \"hidden\", ...ASPECT_STYLES[aspectRatio] }}\n >\n <img\n src={activeImage}\n alt={productName}\n style={{\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n opacity: fadeIn ? 1 : 0,\n transition: \"opacity 300ms ease\",\n }}\n data-cimplify-image-gallery-active\n />\n </div>\n\n {normalizedImages.length > 1 && (\n <RadioGroup\n aria-label={`${productName} image thumbnails`}\n value={String(selectedImage)}\n onValueChange={(value) => setSelectedImage(Number(value))}\n data-cimplify-image-gallery-thumbnails\n style={{ display: \"flex\", gap: \"0.5rem\", marginTop: \"0.75rem\" }}\n >\n {normalizedImages.map((image, index) => {\n const isSelected = selectedImage === index;\n return (\n <Radio.Root\n key={`${image}-${index}`}\n value={String(index)}\n data-cimplify-image-gallery-thumb\n data-selected={isSelected || undefined}\n style={{\n width: \"4rem\",\n height: \"4rem\",\n overflow: \"hidden\",\n padding: 0,\n border: \"none\",\n cursor: \"pointer\",\n }}\n >\n <img\n src={image}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n"
10
+ "content": "\"use client\";\n\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { preloadImage } from \"./utils/preload-image\";\n\nexport interface ProductImageGalleryProps {\n images: string[];\n productName: string;\n aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n className?: string;\n}\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\n/**\n * ProductImageGallery — main image + thumbnail strip.\n *\n * Uses plain `<img>` for framework-agnostic rendering (not Next.js Image).\n */\nexport function ProductImageGallery({\n images,\n productName,\n aspectRatio = \"4/3\",\n className,\n}: ProductImageGalleryProps): React.ReactElement | null {\n const normalizedImages = useMemo(\n () =>\n images.filter(\n (image): image is string =>\n typeof image === \"string\" && image.trim().length > 0,\n ),\n [images],\n );\n\n const [selectedImage, setSelectedImage] = useState(0);\n const [fadeIn, setFadeIn] = useState(true);\n\n useEffect(() => {\n setSelectedImage(0);\n }, [normalizedImages.length, productName]);\n\n // Cross-fade the main image whenever the selection changes (thumbnail click\n // or a variant swapping in its own gallery).\n useEffect(() => {\n setFadeIn(false);\n const id = requestAnimationFrame(() => setFadeIn(true));\n return () => cancelAnimationFrame(id);\n }, [selectedImage]);\n\n if (normalizedImages.length === 0) {\n return null;\n }\n\n const activeImage = normalizedImages[selectedImage] || normalizedImages[0];\n\n preloadImage(normalizedImages[selectedImage + 1]);\n preloadImage(normalizedImages[selectedImage - 1]);\n\n return (\n <div data-cimplify-image-gallery className={className}>\n <div\n data-cimplify-image-gallery-main\n style={{ position: \"relative\", overflow: \"hidden\", ...ASPECT_STYLES[aspectRatio] }}\n >\n <img\n src={activeImage}\n alt={productName}\n fetchPriority=\"high\"\n style={{\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n opacity: fadeIn ? 1 : 0,\n transition: \"opacity 300ms ease\",\n }}\n data-cimplify-image-gallery-active\n />\n </div>\n\n {normalizedImages.length > 1 && (\n <RadioGroup\n aria-label={`${productName} image thumbnails`}\n value={String(selectedImage)}\n onValueChange={(value) => setSelectedImage(Number(value))}\n data-cimplify-image-gallery-thumbnails\n style={{ display: \"flex\", gap: \"0.5rem\", marginTop: \"0.75rem\" }}\n >\n {normalizedImages.map((image, index) => {\n const isSelected = selectedImage === index;\n return (\n <Radio.Root\n key={`${image}-${index}`}\n value={String(index)}\n data-cimplify-image-gallery-thumb\n data-selected={isSelected || undefined}\n style={{\n width: \"4rem\",\n height: \"4rem\",\n overflow: \"hidden\",\n padding: 0,\n border: \"none\",\n cursor: \"pointer\",\n }}\n >\n <img\n src={image}\n alt=\"\"\n loading=\"lazy\"\n decoding=\"async\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n"
11
11
  }
12
12
  ]
13
13
  }
@@ -14,7 +14,7 @@
14
14
  "files": [
15
15
  {
16
16
  "path": "product-page.tsx",
17
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { PRODUCT_TYPE, RENDER_HINT } from \"@cimplify/sdk\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport { DefaultProductLayout } from \"@cimplify/sdk/react\";\nimport { FoodProductLayout } from \"@cimplify/sdk/react\";\nimport { WholesaleProductLayout } from \"@cimplify/sdk/react\";\nimport { ServiceProductLayout } from \"@cimplify/sdk/react\";\nimport { DigitalProductLayout } from \"@cimplify/sdk/react\";\nimport { BundleProductLayout } from \"./layouts/bundle-product-layout\";\nimport { CompositeProductLayout } from \"./layouts/composite-product-layout\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = \"default\",\n Food = \"food\",\n Wholesale = \"wholesale\",\n Service = \"service\",\n Digital = \"digital\",\n Bundle = \"bundle\",\n Composite = \"composite\",\n Physical = \"physical\",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID — used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority — maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === \"string\" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || \"\";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className=\"grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse\">\n <div className=\"aspect-square bg-muted\" />\n <div className=\"space-y-4 py-4\">\n <div className=\"h-8 w-3/5 bg-muted\" />\n <div className=\"h-6 w-2/5 bg-muted\" />\n <div className=\"h-4 w-4/5 bg-muted\" />\n <div className=\"h-4 w-3/5 bg-muted\" />\n <div className=\"h-14 w-full bg-muted mt-8\" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className=\"text-muted-foreground\">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n"
17
+ "content": "\"use client\";\n\nimport React, { useEffect } from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { PRODUCT_TYPE, RENDER_HINT } from \"@cimplify/sdk\";\nimport { trackProductView } from \"./hooks/use-product\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport { DefaultProductLayout } from \"@cimplify/sdk/react\";\nimport { FoodProductLayout } from \"@cimplify/sdk/react\";\nimport { WholesaleProductLayout } from \"@cimplify/sdk/react\";\nimport { ServiceProductLayout } from \"@cimplify/sdk/react\";\nimport { DigitalProductLayout } from \"@cimplify/sdk/react\";\nimport { BundleProductLayout } from \"./layouts/bundle-product-layout\";\nimport { CompositeProductLayout } from \"./layouts/composite-product-layout\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = \"default\",\n Food = \"food\",\n Wholesale = \"wholesale\",\n Service = \"service\",\n Digital = \"digital\",\n Bundle = \"bundle\",\n Composite = \"composite\",\n Physical = \"physical\",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID — used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority — maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === \"string\" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || \"\";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n const context = useOptionalCimplifyClient();\n useEffect(() => {\n if (product && context) {\n trackProductView(context.client, product);\n }\n }, [context, product]);\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className=\"grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse\">\n <div className=\"aspect-square bg-muted\" />\n <div className=\"space-y-4 py-4\">\n <div className=\"h-8 w-3/5 bg-muted\" />\n <div className=\"h-6 w-2/5 bg-muted\" />\n <div className=\"h-4 w-4/5 bg-muted\" />\n <div className=\"h-4 w-3/5 bg-muted\" />\n <div className=\"h-14 w-full bg-muted mt-8\" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className=\"text-muted-foreground\">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n"
18
18
  }
19
19
  ]
20
20
  }
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "recently-viewed.tsx",
13
- "content": "\"use client\";\n\nimport React from \"react\";\nimport { useActivityState } from \"./hooks/use-activity-state\";\nimport { cn } from \"@cimplify/sdk/react\";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed — displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type=\"button\"\n onClick={() => onProductClick?.(product)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n"
13
+ "content": "\"use client\";\n\nimport React, { useCallback } from \"react\";\nimport { useActivityState } from \"./hooks/use-activity-state\";\nimport { prefetchProduct } from \"./hooks/use-product\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed — displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const context = useOptionalCimplifyClient();\n const warmViewed = useCallback(\n (productId: string) => {\n if (!context || context.prefetch.mode === \"off\") return;\n speculate(() => prefetchProduct(context.client, productId));\n },\n [context],\n );\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type=\"button\"\n onClick={() => onProductClick?.(product)}\n onPointerEnter={() => warmViewed(product.product_id)}\n onFocus={() => warmViewed(product.product_id)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "recommendation-carousel.tsx",
13
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { ActivityRecommendation } from \"../activity\";\nimport { useRecommendations } from \"./hooks/use-recommendations\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel — displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", overflowX: \"auto\", gap: \"1rem\" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type=\"button\"\n onClick={() => onProductClick?.(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? \"Product\"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
13
+ "content": "\"use client\";\n\nimport React, { useCallback } from \"react\";\nimport type { ActivityRecommendation } from \"../activity\";\nimport { useRecommendations } from \"./hooks/use-recommendations\";\nimport { prefetchProduct } from \"./hooks/use-product\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel — displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n const context = useOptionalCimplifyClient();\n const warmRecommendation = useCallback(\n (rec: ActivityRecommendation) => {\n const productId = rec.product?.id;\n if (!productId || !context || context.prefetch.mode === \"off\") return;\n speculate(() => prefetchProduct(context.client, productId));\n },\n [context],\n );\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", overflowX: \"auto\", gap: \"1rem\" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type=\"button\"\n onClick={() => onProductClick?.(rec)}\n onPointerEnter={() => warmRecommendation(rec)}\n onFocus={() => warmRecommendation(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? \"Product\"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "search-input.tsx",
12
- "content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport { useSearch } from \"@cimplify/sdk/react\";\nimport type { UseSearchOptions } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { formatPrice } from \"@cimplify/sdk\";\n\nexport interface SearchInputClassNames {\n root?: string;\n input?: string;\n clearButton?: string;\n results?: string;\n resultItem?: string;\n empty?: string;\n loading?: string;\n resultImage?: string;\n resultName?: string;\n resultPrice?: string;\n categoryFilter?: string;\n}\n\nexport interface SearchInputProps {\n /** Placeholder text for the input. */\n placeholder?: string;\n /** Search options forwarded to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Called when a product result is clicked. */\n onResultClick?: (product: import(\"../types/product\").Product) => void;\n /** Custom result item renderer. */\n renderResult?: (product: import(\"../types/product\").Product) => React.ReactNode;\n /** Show inline results dropdown. Default: true. */\n showResults?: boolean;\n /** Optional category ID to scope search results. */\n categoryId?: string;\n /** Display name for the active category filter. */\n categoryName?: string;\n className?: string;\n classNames?: SearchInputClassNames;\n}\n\nconst SearchIcon = () => (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n data-cimplify-search-icon\n >\n <circle cx=\"11\" cy=\"11\" r=\"8\" />\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\" />\n </svg>\n);\n\n/**\n * SearchInput — search bar with debounced results dropdown.\n *\n * Wraps `useSearch` with a controlled input and optional inline results list.\n * Uses Base UI Field.Root + Input for accessible form semantics.\n */\nexport function SearchInput({\n placeholder = \"Search products...\",\n searchOptions,\n onResultClick,\n renderResult,\n showResults = true,\n categoryId,\n categoryName,\n className,\n classNames,\n}: SearchInputProps): React.ReactElement {\n const mergedOptions = useMemo<UseSearchOptions>(\n () => (categoryId ? { ...searchOptions, category: categoryId } : searchOptions ?? {}),\n [searchOptions, categoryId],\n );\n const { results, isLoading, query, setQuery, clear } = useSearch(mergedOptions);\n\n const handleValueChange = useCallback(\n (value: string) => {\n setQuery(value);\n },\n [setQuery],\n );\n\n return (\n <Field.Root\n data-cimplify-search\n className={cn(className, classNames?.root)}\n style={{ position: \"relative\" }}\n >\n <Field.Label className=\"sr-only\">Search products</Field.Label>\n\n <div style={{ position: \"relative\", display: \"flex\", alignItems: \"center\" }}>\n <span\n data-cimplify-search-icon-wrapper\n style={{\n position: \"absolute\",\n left: \"0.5rem\",\n pointerEvents: \"none\",\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <SearchIcon />\n </span>\n\n <Input\n type=\"search\"\n value={query}\n onValueChange={handleValueChange}\n placeholder={placeholder}\n data-cimplify-search-input\n className={classNames?.input}\n />\n\n {query.length > 0 && (\n <button\n type=\"button\"\n onClick={clear}\n data-cimplify-search-clear\n className={classNames?.clearButton}\n aria-label=\"Clear search\"\n >\n &times;\n </button>\n )}\n </div>\n\n {categoryId && (\n <div data-cimplify-search-category-filter className={classNames?.categoryFilter}>\n Filtered by: {categoryName || categoryId}\n </div>\n )}\n\n {showResults && query.length > 0 && (\n <div data-cimplify-search-results className={classNames?.results}>\n {isLoading && (\n <div data-cimplify-search-loading className={classNames?.loading} aria-busy=\"true\">\n Searching...\n </div>\n )}\n\n {!isLoading && results.length === 0 && query.length >= 2 && (\n <div data-cimplify-search-empty className={classNames?.empty}>\n No results found\n </div>\n )}\n\n {!isLoading &&\n results.map((product) => (\n <button\n key={product.id}\n type=\"button\"\n onClick={() => onResultClick?.(product)}\n data-cimplify-search-result\n className={classNames?.resultItem}\n style={!renderResult ? { display: \"flex\", alignItems: \"center\", gap: \"0.5rem\", width: \"100%\" } : undefined}\n >\n {renderResult ? (\n renderResult(product)\n ) : (\n <>\n {(product.images?.[0] || product.image_url) && (\n <img\n src={product.images?.[0] || product.image_url}\n alt=\"\"\n data-cimplify-search-result-image\n className={classNames?.resultImage}\n style={{ width: \"2rem\", height: \"2rem\", objectFit: \"cover\", borderRadius: \"0.25rem\", flexShrink: 0 }}\n />\n )}\n <span data-cimplify-search-result-name className={classNames?.resultName} style={{ flex: 1, textAlign: \"left\" }}>\n {product.name}\n </span>\n {product.default_price !== undefined && (\n <span data-cimplify-search-result-price className={classNames?.resultPrice} style={{ flexShrink: 0 }}>\n {formatPrice(product.default_price)}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n )}\n </Field.Root>\n );\n}\n"
12
+ "content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { useSearch } from \"@cimplify/sdk/react\";\nimport type { UseSearchOptions } from \"@cimplify/sdk/react\";\nimport { prefetchProduct } from \"./hooks/use-product\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { formatPrice } from \"@cimplify/sdk\";\n\nexport interface SearchInputClassNames {\n root?: string;\n input?: string;\n clearButton?: string;\n results?: string;\n resultItem?: string;\n empty?: string;\n loading?: string;\n resultImage?: string;\n resultName?: string;\n resultPrice?: string;\n categoryFilter?: string;\n}\n\nexport interface SearchInputProps {\n /** Placeholder text for the input. */\n placeholder?: string;\n /** Search options forwarded to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Called when a product result is clicked. */\n onResultClick?: (product: Product) => void;\n /** Custom result item renderer. */\n renderResult?: (product: Product) => React.ReactNode;\n /** Show inline results dropdown. Default: true. */\n showResults?: boolean;\n /** Optional category ID to scope search results. */\n categoryId?: string;\n /** Display name for the active category filter. */\n categoryName?: string;\n className?: string;\n classNames?: SearchInputClassNames;\n}\n\nconst SearchIcon = () => (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n data-cimplify-search-icon\n >\n <circle cx=\"11\" cy=\"11\" r=\"8\" />\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\" />\n </svg>\n);\n\n/**\n * SearchInput — search bar with debounced results dropdown.\n *\n * Wraps `useSearch` with a controlled input and optional inline results list.\n * Uses Base UI Field.Root + Input for accessible form semantics.\n */\nexport function SearchInput({\n placeholder = \"Search products...\",\n searchOptions,\n onResultClick,\n renderResult,\n showResults = true,\n categoryId,\n categoryName,\n className,\n classNames,\n}: SearchInputProps): React.ReactElement {\n const mergedOptions = useMemo<UseSearchOptions>(\n () => (categoryId ? { ...searchOptions, category: categoryId } : searchOptions ?? {}),\n [searchOptions, categoryId],\n );\n const { results, isLoading, query, setQuery, clear } = useSearch(mergedOptions);\n\n const context = useOptionalCimplifyClient();\n const warmResult = useCallback(\n (product: Product) => {\n const client = mergedOptions.client ?? context?.client;\n if (!client || context?.prefetch.mode === \"off\") return;\n speculate(() => prefetchProduct(client, product.slug ?? product.id));\n },\n [mergedOptions.client, context],\n );\n\n const handleValueChange = useCallback(\n (value: string) => {\n setQuery(value);\n },\n [setQuery],\n );\n\n return (\n <Field.Root\n data-cimplify-search\n className={cn(className, classNames?.root)}\n style={{ position: \"relative\" }}\n >\n <Field.Label className=\"sr-only\">Search products</Field.Label>\n\n <div style={{ position: \"relative\", display: \"flex\", alignItems: \"center\" }}>\n <span\n data-cimplify-search-icon-wrapper\n style={{\n position: \"absolute\",\n left: \"0.5rem\",\n pointerEvents: \"none\",\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <SearchIcon />\n </span>\n\n <Input\n type=\"search\"\n value={query}\n onValueChange={handleValueChange}\n placeholder={placeholder}\n data-cimplify-search-input\n className={classNames?.input}\n />\n\n {query.length > 0 && (\n <button\n type=\"button\"\n onClick={clear}\n data-cimplify-search-clear\n className={classNames?.clearButton}\n aria-label=\"Clear search\"\n >\n &times;\n </button>\n )}\n </div>\n\n {categoryId && (\n <div data-cimplify-search-category-filter className={classNames?.categoryFilter}>\n Filtered by: {categoryName || categoryId}\n </div>\n )}\n\n {showResults && query.length > 0 && (\n <div data-cimplify-search-results className={classNames?.results}>\n {isLoading && (\n <div data-cimplify-search-loading className={classNames?.loading} aria-busy=\"true\">\n Searching...\n </div>\n )}\n\n {!isLoading && results.length === 0 && query.length >= 2 && (\n <div data-cimplify-search-empty className={classNames?.empty}>\n No results found\n </div>\n )}\n\n {!isLoading &&\n results.map((product) => (\n <button\n key={product.id}\n type=\"button\"\n onClick={() => onResultClick?.(product)}\n onPointerEnter={() => warmResult(product)}\n onFocus={() => warmResult(product)}\n data-cimplify-search-result\n className={classNames?.resultItem}\n style={!renderResult ? { display: \"flex\", alignItems: \"center\", gap: \"0.5rem\", width: \"100%\" } : undefined}\n >\n {renderResult ? (\n renderResult(product)\n ) : (\n <>\n {(product.images?.[0] || product.image_url) && (\n <img\n src={product.images?.[0] || product.image_url}\n alt=\"\"\n data-cimplify-search-result-image\n className={classNames?.resultImage}\n style={{ width: \"2rem\", height: \"2rem\", objectFit: \"cover\", borderRadius: \"0.25rem\", flexShrink: 0 }}\n />\n )}\n <span data-cimplify-search-result-name className={classNames?.resultName} style={{ flex: 1, textAlign: \"left\" }}>\n {product.name}\n </span>\n {product.default_price !== undefined && (\n <span data-cimplify-search-result-price className={classNames?.resultPrice} style={{ flexShrink: 0 }}>\n {formatPrice(product.default_price)}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n )}\n </Field.Root>\n );\n}\n"
13
13
  }
14
14
  ]
15
15
  }