@cimplify/cli 0.7.25 → 0.8.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.
- package/dist/{add-DMA7USQR.mjs → add-TCU5T7Q2.mjs} +1 -1
- package/dist/{assets-74SK63TR.mjs → assets-EHRFAWZI.mjs} +1 -1
- package/dist/{auth-step-up-BIUYQJP6.mjs → auth-step-up-RWBXYJKR.mjs} +1 -1
- package/dist/{cancel-HANLRA6Q.mjs → cancel-DAO23NIX.mjs} +1 -1
- package/dist/{chunk-5SVTTTD2.mjs → chunk-7DOC7MLJ.mjs} +1 -1
- package/dist/{chunk-NUXKAEX5.mjs → chunk-BQ5CWVHQ.mjs} +1 -1
- package/dist/{chunk-MAOO6ZZ5.mjs → chunk-CV3ZFCX6.mjs} +33 -9
- package/dist/{chunk-CLNS2NBR.mjs → chunk-IV7SOUH3.mjs} +1 -1
- package/dist/{chunk-ML6JUUXS.mjs → chunk-MBLJKDCR.mjs} +1 -1
- package/dist/{deploy-YVCYHNSS.mjs → deploy-M63QHHOD.mjs} +2 -2
- package/dist/{dev-ONW2S77K.mjs → dev-VE6VVDEU.mjs} +1 -1
- package/dist/dispatcher.mjs +24 -24
- package/dist/{doctor-IOEKC6LJ.mjs → doctor-TBQN7ZSR.mjs} +3 -3
- package/dist/{domains-6GJASWJU.mjs → domains-7QM444PU.mjs} +1 -1
- package/dist/{env-7ISJ73YI.mjs → env-VKTOPGSZ.mjs} +1 -1
- package/dist/{explain-GBKONBB3.mjs → explain-LFL4IXXK.mjs} +1 -1
- package/dist/{introspect-PJ6SLINK.mjs → introspect-SHUBKCPY.mjs} +2 -2
- package/dist/{link-DZSILT5N.mjs → link-746RR2HY.mjs} +1 -1
- package/dist/{list-ZTKE2YVE.mjs → list-BVSFEBG7.mjs} +1 -1
- package/dist/{login-3OD4ND2H.mjs → login-EMJ33OD4.mjs} +31 -37
- package/dist/{logs-YNN2PQ24.mjs → logs-DSYUOYQJ.mjs} +1 -1
- package/dist/{projects-QSUWVTJZ.mjs → projects-QKI6CTCD.mjs} +1 -1
- package/dist/{repo-KNQMSPVV.mjs → repo-3BEDOFZX.mjs} +2 -2
- package/dist/{rollback-DD4RNRFM.mjs → rollback-ZR6X6IQ5.mjs} +1 -1
- package/dist/{status-JSYXM5RT.mjs → status-AIENVHAV.mjs} +1 -1
- package/dist/{update-BZ53TKVN.mjs → update-THXRRDNG.mjs} +1 -1
- package/dist/{whoami-DNZ7RUTH.mjs → whoami-LPQMPYTE.mjs} +1 -1
- package/package.json +1 -1
|
@@ -6449,7 +6449,7 @@ export function stripHtmlToText(html: string | null | undefined): string {
|
|
|
6449
6449
|
.replace(/\\s+/g, " ")
|
|
6450
6450
|
.trim();
|
|
6451
6451
|
}
|
|
6452
|
-
` }] }, "cart-drawer": { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"], "files": [{ "path": "cart-drawer.tsx", "content": '"use client";\n\nimport React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";\nimport { CartSummary } from "./cart-summary";\nimport { Price } from "@cimplify/sdk/react";\nimport { useCart } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface CartDrawerCtx {\n isOpen: boolean;\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\nconst CartDrawerContext = createContext<CartDrawerCtx | null>(null);\n\nexport function useCartDrawer(): CartDrawerCtx {\n const ctx = useContext(CartDrawerContext);\n if (!ctx) {\n throw new Error("useCartDrawer must be used within <CartDrawerProvider>");\n }\n return ctx;\n}\n\ninterface ProviderProps {\n children: React.ReactNode;\n /** Auto-open the drawer whenever the cart\'s pendingOpCount goes from 0 \u2192 >0. Default: true. */\n openOnAdd?: boolean;\n}\n\nexport function CartDrawerProvider({ children, openOnAdd = true }: ProviderProps): React.ReactElement {\n const [isOpen, setIsOpen] = useState(false);\n const cart = useCart();\n const lastPendingRef = useRef(0);\n\n const open = useCallback(() => setIsOpen(true), []);\n const close = useCallback(() => setIsOpen(false), []);\n const toggle = useCallback(() => setIsOpen((v) => !v), []);\n\n useEffect(() => {\n if (!openOnAdd) return;\n if (cart.pendingOpCount > lastPendingRef.current && cart.pendingOpCount > 0) {\n setIsOpen(true);\n }\n lastPendingRef.current = cart.pendingOpCount;\n }, [cart.pendingOpCount, openOnAdd]);\n\n const value = useMemo<CartDrawerCtx>(() => ({ isOpen, open, close, toggle }), [isOpen, open, close, toggle]);\n return <CartDrawerContext.Provider value={value}>{children}</CartDrawerContext.Provider>;\n}\n\nexport interface CartDrawerProps {\n /** Called when "Checkout" is clicked. Drawer auto-closes first. */\n onCheckout?: () => void;\n /** Called when "Continue Shopping" is clicked. Defaults to closing. */\n onContinueShopping?: () => void;\n /** Called when the empty state\'s CTA is clicked. */\n onShop?: () => void;\n /** Heading. */\n title?: string;\n /** Free-shipping threshold (in business currency, numeric). 0 disables the progress bar. */\n freeShippingThreshold?: number;\n /** Custom class on the panel. */\n className?: string;\n}\n\n/**\n * Animate a number toward `target` over ~250ms. Used for subtotal so it\n * feels alive when items are added/removed.\n */\nfunction useAnimatedNumber(target: number, durationMs = 250) {\n const [value, setValue] = useState(target);\n const fromRef = useRef(target);\n const startRef = useRef<number | null>(null);\n useEffect(() => {\n fromRef.current = value;\n startRef.current = null;\n let raf = 0;\n const tick = (t: number) => {\n if (startRef.current == null) startRef.current = t;\n const p = Math.min(1, (t - startRef.current) / durationMs);\n const eased = 1 - Math.pow(1 - p, 3);\n setValue(fromRef.current + (target - fromRef.current) * eased);\n if (p < 1) raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n // value intentionally omitted \u2014 only re-trigger on target change\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, durationMs]);\n return value;\n}\n\nexport function CartDrawer({\n onCheckout,\n onContinueShopping,\n onShop,\n title = "Cart",\n freeShippingThreshold = 0,\n className,\n}: CartDrawerProps): React.ReactElement {\n const { isOpen, close } = useCartDrawer();\n const cart = useCart();\n const subtotalNum = parsePrice(cart.subtotal);\n const animatedSubtotal = useAnimatedNumber(subtotalNum);\n\n // Lock body scroll + close on Escape while open.\n useEffect(() => {\n if (!isOpen) return;\n const original = document.body.style.overflow;\n document.body.style.overflow = "hidden";\n const onKey = (e: KeyboardEvent) => {\n if (e.key === "Escape") close();\n };\n window.addEventListener("keydown", onKey);\n return () => {\n document.body.style.overflow = original;\n window.removeEventListener("keydown", onKey);\n };\n }, [isOpen, close]);\n\n const handleCheckout = () => {\n close();\n onCheckout?.();\n };\n\n const handleContinue = () => {\n onContinueShopping?.();\n close();\n };\n\n const handleShop = () => {\n onShop?.();\n close();\n };\n\n // Free-shipping progress\n const showShippingBar = freeShippingThreshold > 0 && !cart.isEmpty;\n const remainingForShipping = Math.max(0, freeShippingThreshold - subtotalNum);\n const shippingProgress = freeShippingThreshold > 0 ? Math.min(100, (subtotalNum / freeShippingThreshold) * 100) : 0;\n const shippingUnlocked = subtotalNum >= freeShippingThreshold && freeShippingThreshold > 0;\n\n return (\n <div\n data-cimplify-cart-drawer\n data-open={isOpen ? "true" : "false"}\n aria-hidden={!isOpen}\n className={cn(\n "fixed inset-0 z-[200]",\n isOpen ? "pointer-events-auto" : "pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n onClick={close}\n className={cn(\n "absolute inset-0 bg-foreground/40 backdrop-blur-sm transition-opacity duration-300",\n isOpen ? "opacity-100" : "opacity-0",\n )}\n />\n\n {/* Panel */}\n <aside\n role="dialog"\n aria-modal="true"\n aria-label={title}\n className={cn(\n "absolute top-0 right-0 h-full w-full sm:max-w-[480px] bg-background shadow-2xl flex flex-col",\n "transition-transform duration-300",\n isOpen ? "translate-x-0" : "translate-x-full",\n // ease-out cubic-bezier for a "pull" feel\n "[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]",\n className,\n )}\n >\n {/* Header */}\n <header className="relative flex items-center justify-between gap-4 px-6 py-5 shrink-0">\n <div className="flex items-baseline gap-2">\n <h2 className="text-xl font-bold tracking-tight m-0">{title}</h2>\n {cart.itemCount > 0 && (\n <span className="text-sm text-muted-foreground tabular-nums">\n {cart.itemCount} {cart.itemCount === 1 ? "item" : "items"}\n </span>\n )}\n </div>\n <button\n type="button"\n onClick={close}\n aria-label="Close cart"\n className="grid place-items-center w-9 h-9 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"\n >\n <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </header>\n\n {/* Free-shipping bar */}\n {showShippingBar && (\n <div data-cimplify-cart-shipping-bar className="px-6 pb-4 shrink-0">\n <div className="flex items-baseline justify-between gap-2 mb-1.5 text-xs">\n {shippingUnlocked ? (\n <span className="font-medium text-primary">\u2713 Free shipping unlocked</span>\n ) : (\n <span className="text-muted-foreground">\n <Price amount={remainingForShipping} className="font-semibold text-foreground" /> away from free shipping\n </span>\n )}\n </div>\n <div className="h-1 rounded-full bg-muted overflow-hidden">\n <div\n className="h-full bg-primary transition-[width] duration-500 ease-out"\n style={{ width: `${shippingProgress}%` }}\n />\n </div>\n </div>\n )}\n\n {/* Items */}\n <div className="flex-1 overflow-y-auto px-6">\n {cart.isEmpty ? (\n <EmptyState onShop={handleShop} />\n ) : (\n <CartSummary showTotals={false} showCheckoutButton={false} />\n )}\n </div>\n\n {/* Footer */}\n {!cart.isEmpty && (\n <footer className="border-t border-border px-6 py-5 shrink-0 space-y-3 bg-background">\n <div className="flex items-baseline justify-between">\n <span className="text-sm text-muted-foreground">Subtotal</span>\n <Price amount={animatedSubtotal} className="text-lg font-bold tabular-nums" />\n </div>\n <p className="text-[11px] text-muted-foreground">\n Tax and shipping calculated at checkout.\n </p>\n <button\n type="button"\n onClick={handleCheckout}\n className="w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Checkout \u2014 <Price amount={cart.subtotal} className="tabular-nums" />\n </button>\n <button\n type="button"\n onClick={handleContinue}\n className="w-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"\n >\n Continue shopping\n </button>\n <div className="flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase tracking-wider pt-1">\n <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />\n </svg>\n <span>Secure checkout</span>\n </div>\n </footer>\n )}\n </aside>\n </div>\n );\n}\n\nfunction EmptyState({ onShop }: { onShop: () => void }) {\n return (\n <div data-cimplify-cart-empty className="h-full grid place-items-center text-center py-16">\n <div className="space-y-5 max-w-[260px]">\n <div className="relative w-20 h-20 mx-auto">\n <div className="absolute inset-0 rounded-full bg-muted" />\n <svg\n className="relative w-10 h-10 m-auto top-1/2 -translate-y-1/2 text-muted-foreground/60"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l-1 12H6L5 9z" />\n </svg>\n </div>\n <div>\n <p className="text-base font-semibold m-0">Your cart is empty</p>\n <p className="text-sm text-muted-foreground mt-1">\n Discover something you\'ll love.\n </p>\n </div>\n <button\n type="button"\n onClick={onShop}\n className="inline-flex items-center gap-1.5 h-11 px-6 rounded-full bg-foreground text-background text-sm font-semibold hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Shop now\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />\n </svg>\n </button>\n </div>\n </div>\n );\n}\n' }] }, "product-sheet": { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "rich-text", "cn"], "files": [{ "path": "product-sheet.tsx", "content": '"use client";\n\nimport React, { useState } from "react";\nimport type { Product, ProductWithDetails, VariantView } from "@cimplify/sdk";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default 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: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return "variants" in product;\n}\n\n/**\n * ProductSheet \u2014 full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: "flex",\n flexDirection: "column",\n gap: "1rem",\n }}\n >\n <div\n style={{\n aspectRatio: "4/3",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.5rem",\n }}\n />\n <div\n style={{\n height: "1.5rem",\n width: "60%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n <div\n style={{\n height: "1rem",\n width: "30%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: "flex", flexDirection: "column", gap: "1rem" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n currency={getProductCurrency(fullProduct)}\n className={classNames?.price}\n />\n </div>\n\n {/* Description \u2014 merchant-authored HTML from the catalogue. */}\n <RichText\n html={fullProduct.description}\n data-cimplify-product-sheet-description\n className={cn("text-sm", classNames?.description)}\n />\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "customer-input-fields": { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "customer-input-fields.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { INPUT_FIELD_TYPE, type AddressValue, type DateRangeValue, type LocationValue, type PhoneValue, type ProductInputField, type SignatureValue } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { DatePicker } from "./date-picker";\nimport { TimePicker } from "./time-picker";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CustomerInputFieldsClassNames {\n root?: string;\n field?: string;\n label?: string;\n required?: string;\n helpText?: string;\n input?: string;\n select?: string;\n textarea?: string;\n fileInput?: string;\n colorInput?: string;\n radioGroup?: string;\n radioOption?: string;\n checkboxLabel?: string;\n priceAdjustment?: string;\n addressInput?: string;\n phoneInput?: string;\n signatureCanvas?: string;\n multiSelectGroup?: string;\n multiSelectOption?: string;\n dateRangeInput?: string;\n locationInput?: string;\n}\n\nexport interface CustomerInputFieldsProps {\n fields: ProductInputField[];\n values: Record<string, unknown>;\n onChange: (values: Record<string, unknown>) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n currency?: CurrencyCode;\n className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\n currency,\n className,\n classNames,\n}: CustomerInputFieldsProps): React.ReactElement | null {\n const clientContext = useOptionalCimplifyClient();\n\n if (fields.length === 0) return null;\n\n const sorted = [...fields].sort((a, b) => a.display_order - b.display_order);\n\n const setValue = useCallback(\n (fieldId: string, value: unknown) => {\n onChange({ ...values, [fieldId]: value });\n },\n [values, onChange],\n );\n\n const defaultFileUpload = useCallback(\n async (file: File): Promise<string> => {\n if (onFileUpload) return onFileUpload(file, {} as ProductInputField);\n if (!clientContext?.client) throw new Error("No upload provider available");\n const result = await clientContext.client.uploads.upload(file);\n if (!result.ok) throw new Error(result.error.message);\n return result.value.url;\n },\n [onFileUpload, clientContext],\n );\n\n return (\n <div data-cimplify-customer-inputs className={cn("space-y-4", className, classNames?.root)}>\n {sorted.map((field) => (\n <InputField\n key={field.id}\n field={field}\n value={values[field.id]}\n onValueChange={(value) => setValue(field.id, value)}\n onFileUpload={onFileUpload ?? (clientContext?.client ? (_file, _field) => defaultFileUpload(_file) : undefined)}\n currency={currency}\n classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\n field,\n value,\n onValueChange,\n onFileUpload,\n currency,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n currency?: CurrencyCode;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const hasPriceAdjustment = field.price_adjustment != null && parsePrice(field.price_adjustment) > 0;\n\n return (\n <div data-cimplify-customer-input data-field-type={field.field_type} className={classNames?.field}>\n <div className="flex items-center gap-2 mb-1.5">\n <label className={cn("text-sm font-semibold", classNames?.label)}>\n {field.name}\n </label>\n {field.is_required && (\n <span className={cn("text-xs text-destructive", classNames?.required)}>Required</span>\n )}\n {hasPriceAdjustment && (\n <span className={cn("text-xs text-muted-foreground", classNames?.priceAdjustment)}>\n +<Price amount={field.price_adjustment!} currency={currency} />\n </span>\n )}\n </div>\n\n {field.help_text && (\n <p className={cn("text-xs text-muted-foreground mb-1.5", classNames?.helpText)}>\n {field.help_text}\n </p>\n )}\n\n <FieldInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n </div>\n );\n}\n\nfunction FieldInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const inputClass = cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring",\n classNames?.input,\n );\n\n switch (field.field_type) {\n case INPUT_FIELD_TYPE.Text:\n case INPUT_FIELD_TYPE.Url:\n return (\n <input\n type={field.field_type === INPUT_FIELD_TYPE.Url ? "url" : "text"}\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Textarea:\n return (\n <textarea\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n rows={3}\n className={cn(inputClass, "resize-none", classNames?.textarea)}\n />\n );\n\n case INPUT_FIELD_TYPE.Number:\n return (\n <input\n type="number"\n value={typeof value === "number" ? value : ""}\n onChange={(e) => onValueChange(e.target.value ? parseFloat(e.target.value) : undefined)}\n placeholder={field.placeholder}\n required={field.is_required}\n min={field.validation?.min_value ?? undefined}\n max={field.validation?.max_value ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Select:\n return (\n <select\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={cn(inputClass, classNames?.select)}\n >\n <option value="">{field.placeholder || "Select..."}</option>\n {(field.options || []).map((opt) => (\n <option key={opt} value={opt}>{opt}</option>\n ))}\n </select>\n );\n\n case INPUT_FIELD_TYPE.Radio:\n return (\n <div className={cn("space-y-2", classNames?.radioGroup)}>\n {(field.options || []).map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.radioOption,\n )}\n >\n <input\n type="radio"\n name={field.id}\n value={opt}\n checked={value === opt}\n onChange={() => onValueChange(opt)}\n className="accent-primary"\n />\n {opt}\n </label>\n ))}\n </div>\n );\n\n case INPUT_FIELD_TYPE.Checkbox:\n return (\n <label className={cn("flex items-center gap-2 text-sm cursor-pointer", classNames?.checkboxLabel)}>\n <input\n type="checkbox"\n checked={value === true}\n onChange={(e) => onValueChange(e.target.checked)}\n className="accent-primary w-4 h-4"\n />\n {field.placeholder || field.name}\n </label>\n );\n\n case INPUT_FIELD_TYPE.Color:\n return (\n <input\n type="color"\n value={typeof value === "string" ? value : "#000000"}\n onChange={(e) => onValueChange(e.target.value)}\n className={cn("w-12 h-10 rounded-md border border-input cursor-pointer", classNames?.colorInput)}\n />\n );\n\n case INPUT_FIELD_TYPE.Date:\n return (\n <DatePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.File:\n case INPUT_FIELD_TYPE.Image:\n return (\n <FileUploadInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Email:\n return (\n <input\n type="email"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder || "email@example.com"}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.DateTime: {\n const stringValue = typeof value === "string" ? value : "";\n const [datePart, timePartRaw] = stringValue.includes("T")\n ? stringValue.split("T", 2)\n : [stringValue, ""];\n const timePart = (timePartRaw ?? "").slice(0, 5);\n const commit = (nextDate: string, nextTime: string): void => {\n if (!nextDate && !nextTime) {\n onValueChange(undefined);\n return;\n }\n if (!nextDate) {\n onValueChange(`${nextTime}`);\n return;\n }\n const combined = `${nextDate}T${nextTime || "00:00"}`;\n const parsed = new Date(combined);\n onValueChange(Number.isNaN(parsed.getTime()) ? combined : parsed.toISOString());\n };\n return (\n <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">\n <DatePicker\n value={datePart ?? ""}\n onChange={(next) => commit(next, timePart)}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={`${field.name} date`}\n required={field.is_required}\n />\n <TimePicker\n value={timePart}\n onChange={(next) => commit(datePart ?? "", next)}\n placeholder="Time"\n aria-label={`${field.name} time`}\n />\n </div>\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <TimePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a time"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.MultiSelect:\n return (\n <MultiSelectInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.DateRange:\n return (\n <DateRangeInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Address:\n return (\n <AddressInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Location:\n return (\n <LocationInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Phone:\n return (\n <PhoneInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Signature:\n return (\n <SignatureInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n default:\n return (\n <input\n type="text"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n className={inputClass}\n />\n );\n }\n}\n\nfunction FileUploadInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const [isUploading, setIsUploading] = React.useState(false);\n const fileUrl = typeof value === "string" ? value : undefined;\n const acceptedFormats = field.validation?.accepted_formats;\n const accept = acceptedFormats\n ? acceptedFormats.map((f) => `.${f}`).join(",")\n : field.field_type === INPUT_FIELD_TYPE.Image\n ? "image/*"\n : undefined;\n\n const handleFile = async (file: File) => {\n if (!onFileUpload) return;\n\n if (field.validation?.max_size_mb) {\n const maxBytes = field.validation.max_size_mb * 1024 * 1024;\n if (file.size > maxBytes) {\n return;\n }\n }\n\n setIsUploading(true);\n try {\n const url = await onFileUpload(file, field);\n onValueChange(url);\n } finally {\n setIsUploading(false);\n }\n };\n\n return (\n <div className={cn("space-y-2", classNames?.fileInput)}>\n {fileUrl ? (\n <div className="flex items-center gap-3">\n {field.field_type === INPUT_FIELD_TYPE.Image && (\n <img src={fileUrl} alt="Uploaded" className="w-16 h-16 object-cover rounded-md border border-border" />\n )}\n <div className="flex-1 min-w-0">\n <p className="text-sm text-foreground truncate">{fileUrl.split("/").pop()}</p>\n </div>\n <button\n type="button"\n onClick={() => onValueChange(undefined)}\n className="text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Remove\n </button>\n </div>\n ) : (\n <label className="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-primary/40 transition-colors">\n <input\n type="file"\n accept={accept}\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file) void handleFile(file);\n }}\n className="hidden"\n disabled={isUploading}\n />\n {isUploading ? (\n <span className="text-sm text-muted-foreground">Uploading...</span>\n ) : (\n <>\n <span className="text-sm text-muted-foreground">\n {field.placeholder || `Upload ${field.field_type === INPUT_FIELD_TYPE.Image ? "image" : "file"}`}\n </span>\n {acceptedFormats && (\n <span className="text-xs text-muted-foreground/60 mt-1">\n {acceptedFormats.map((f) => f.toUpperCase()).join(", ")}\n {field.validation?.max_size_mb && ` \xB7 Max ${field.validation.max_size_mb}MB`}\n </span>\n )}\n </>\n )}\n </label>\n )}\n </div>\n );\n}\n\nfunction MultiSelectInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const selected = Array.isArray(value) ? (value as string[]) : [];\n const options = field.options || [];\n const maxSelections = field.validation?.max_selections;\n\n const toggle = (opt: string) => {\n const next = selected.includes(opt)\n ? selected.filter((s) => s !== opt)\n : maxSelections && selected.length >= maxSelections\n ? selected\n : [...selected, opt];\n onValueChange(next.length > 0 ? next : undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.multiSelectGroup)}>\n {options.map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.multiSelectOption,\n )}\n >\n <input\n type="checkbox"\n checked={selected.includes(opt)}\n onChange={() => toggle(opt)}\n className="accent-primary w-4 h-4"\n />\n {opt}\n </label>\n ))}\n {maxSelections && (\n <p className="text-xs text-muted-foreground">\n {selected.length}/{maxSelections} selected\n </p>\n )}\n </div>\n );\n}\n\nfunction DateRangeInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const range = (value && typeof value === "object" ? value : {}) as DateRangeValue;\n\n const update = (key: "start" | "end", v: string) => {\n const next = { ...range, [key]: v };\n onValueChange(next.start || next.end ? next : undefined);\n };\n\n return (\n <div className={cn("grid grid-cols-1 sm:grid-cols-2 gap-3", classNames?.dateRangeInput)}>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">Start</label>\n <DatePicker\n value={range.start || ""}\n onChange={(next) => update("start", next)}\n required={field.is_required}\n placeholder="Start date"\n aria-label={`${field.name} start date`}\n maxDate={range.end ? new Date(`${range.end}T00:00`) : undefined}\n />\n </div>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">End</label>\n <DatePicker\n value={range.end || ""}\n onChange={(next) => update("end", next)}\n required={field.is_required}\n placeholder="End date"\n aria-label={`${field.name} end date`}\n minDate={range.start ? new Date(`${range.start}T00:00`) : undefined}\n />\n </div>\n </div>\n );\n}\n\nfunction AddressInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const addr = (value && typeof value === "object" ? value : null) as AddressValue | null;\n const [inputText, setInputText] = React.useState(addr?.formatted_address ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const fallback = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(fallback);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n const addressValue: AddressValue = {\n formatted_address: d.formatted_address || fallback,\n street_address: d.street_address,\n apartment: d.apartment,\n city: d.city,\n region: d.region,\n postal_code: d.postal_code,\n country: d.country,\n latitude: d.latitude,\n longitude: d.longitude,\n place_id: d.place_id,\n };\n setInputText(addressValue.formatted_address);\n onValueChange(addressValue);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("space-y-2 relative", classNames?.addressInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for an address..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {addr && (\n <input\n type="text"\n value={addr.apartment || ""}\n onChange={(e) => onValueChange({ ...addr, apartment: e.target.value || undefined })}\n placeholder="Apt, suite, unit (optional)"\n className={inputClass}\n />\n )}\n </div>\n );\n}\n\nfunction LocationInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const loc = (value && typeof value === "object" ? value : null) as LocationValue | null;\n const [inputText, setInputText] = React.useState(loc?.label ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const label = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(label);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n onValueChange({\n latitude: d.latitude,\n longitude: d.longitude,\n label: d.formatted_address || label,\n } satisfies LocationValue);\n setInputText(d.formatted_address || label);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("relative", classNames?.locationInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for a location..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {loc && (\n <p className="text-xs text-muted-foreground mt-1">\n {loc.latitude.toFixed(4)}, {loc.longitude.toFixed(4)}\n </p>\n )}\n </div>\n );\n}\n\nfunction PhoneInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const phone = (value && typeof value === "object" ? value : null) as PhoneValue | null;\n const [code, setCode] = React.useState(phone?.country_code ?? "+1");\n const [number, setNumber] = React.useState(phone?.number ?? "");\n\n const update = (nextCode: string, nextNumber: string) => {\n setCode(nextCode);\n setNumber(nextNumber);\n if (nextNumber) {\n onValueChange({\n country_code: nextCode,\n number: nextNumber,\n formatted: `${nextCode} ${nextNumber}`,\n } satisfies PhoneValue);\n } else {\n onValueChange(undefined);\n }\n };\n\n return (\n <div className={cn("flex gap-2", classNames?.phoneInput)}>\n <input\n type="text"\n value={code}\n onChange={(e) => update(e.target.value, number)}\n placeholder="+1"\n className={cn(inputClass, "w-20 shrink-0")}\n />\n <input\n type="tel"\n value={number}\n onChange={(e) => update(code, e.target.value)}\n placeholder={field.placeholder || "Phone number"}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n );\n}\n\nfunction SignatureInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const sig = (value && typeof value === "object" ? value : null) as SignatureValue | null;\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const isDrawing = React.useRef(false);\n\n const getCtx = () => canvasRef.current?.getContext("2d") ?? null;\n\n const startDraw = (e: React.PointerEvent) => {\n const ctx = getCtx();\n if (!ctx) return;\n isDrawing.current = true;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.beginPath();\n ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);\n canvasRef.current!.setPointerCapture(e.pointerId);\n };\n\n const draw = (e: React.PointerEvent) => {\n if (!isDrawing.current) return;\n const ctx = getCtx();\n if (!ctx) return;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.lineWidth = 2;\n ctx.lineCap = "round";\n const style = getComputedStyle(canvasRef.current!);\n ctx.strokeStyle = style.color || "#000";\n ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);\n ctx.stroke();\n };\n\n const endDraw = () => {\n if (!isDrawing.current) return;\n isDrawing.current = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n onValueChange({\n data_url: canvas.toDataURL("image/png"),\n signer_name: sig?.signer_name,\n } satisfies SignatureValue);\n };\n\n const clear = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext("2d");\n if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);\n onValueChange(undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.signatureCanvas)}>\n <div className="relative rounded-md border border-input overflow-hidden">\n <canvas\n ref={canvasRef}\n width={400}\n height={150}\n className="w-full touch-none cursor-crosshair bg-background text-foreground"\n onPointerDown={startDraw}\n onPointerMove={draw}\n onPointerUp={endDraw}\n onPointerLeave={endDraw}\n />\n {sig?.data_url && (\n <button\n type="button"\n onClick={clear}\n className="absolute top-1.5 right-1.5 text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Clear\n </button>\n )}\n </div>\n {!sig?.data_url && (\n <p className="text-xs text-muted-foreground">\n {field.placeholder || "Draw your signature above"}\n </p>\n )}\n </div>\n );\n}\n\nfunction createSessionToken(): string {\n if (typeof crypto !== "undefined" && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `places_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n' }] }, "bundle-selector": { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "bundle-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType, DurationUnit } from "@cimplify/sdk";\nimport type { Money, CurrencyCode } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n currency?: CurrencyCode;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n currency,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef("");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === "fixed" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === "percentage_discount" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === "fixed_discount" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn("space-y-4", className, classNames?.root)}>\n <div\n data-cimplify-bundle-heading\n className={cn("flex items-center justify-between py-3", classNames?.heading)}\n >\n <span className="text-base font-bold">Included in this bundle</span>\n </div>\n\n <div data-cimplify-bundle-components className={cn("divide-y divide-border", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n currency={currency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn("border-t border-border pt-4 flex justify-between text-sm", classNames?.summary)}\n >\n <span className="text-muted-foreground">Bundle price</span>\n <Price amount={bundlePrice} currency={currency} className="font-medium text-primary" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn("flex justify-between text-sm", classNames?.savings)}\n >\n <span className="text-muted-foreground">You save</span>\n <Price amount={discountValue} currency={currency} className="text-green-600 font-medium" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? "0")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\nfunction formatDuration(minutes: number, unit?: DurationUnit): string {\n if (unit === "hours" || (!unit && minutes >= 60 && minutes % 60 === 0)) {\n const h = Math.round(minutes / 60);\n return `${h}h`;\n }\n if (unit === "days" || unit === "nights") {\n const d = Math.round(minutes / 1440);\n return `${d}${unit === "nights" ? "n" : "d"}`;\n }\n return `${minutes}min`;\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n currency?: CurrencyCode;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n currency,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn("py-4", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn("flex items-center justify-between gap-3", classNames?.componentHeader)}\n >\n <div className="flex items-center gap-2">\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-bundle-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service{component.duration_minutes != null && (\n <> · {formatDuration(component.duration_minutes, component.duration_unit)}</>\n )}\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-bundle-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n </div>\n <span className="text-sm text-muted-foreground">\n <Price amount={displayPrice} currency={currency} />\n </span>\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn("mt-3 divide-y divide-border", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n <span\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span className="flex-1 text-sm">\n {variant.display_name}\n </span>\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn("text-sm text-muted-foreground", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? "+" : ""}\n <Price amount={variant.price_adjustment} currency={currency} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "product-model-3d": { "name": "product-model-3d", "title": "ProductModel3D", "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-model-3d.tsx", "content": '"use client";\n\nimport React, { useEffect, useState } from "react";\n\nexport interface ProductModel3DProps {\n /** glTF or GLB URL. */\n src: string;\n /** Optional USDZ URL for iOS AR Quick Look (Safari/WebKit). */\n iosSrc?: string;\n /** Poster image shown while the model loads (and as a fallback). */\n poster?: string;\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "3/4";\n /** Enable AR mode. Lights up on iOS (Quick Look) + Android (Scene Viewer). Default `true`. */\n ar?: boolean;\n autoRotate?: boolean;\n cameraControls?: boolean;\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/9": { aspectRatio: "16/9" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nconst MODEL_VIEWER_CDN =\n "https://unpkg.com/@google/model-viewer@4.0.0/dist/model-viewer.min.js";\n\n// Module-level cache so multiple <ProductModel3D> instances share one script load.\nlet modelViewerLoadPromise: Promise<void> | null = null;\nfunction ensureModelViewer(): Promise<void> {\n if (typeof window === "undefined") return Promise.resolve();\n if (modelViewerLoadPromise) return modelViewerLoadPromise;\n if (window.customElements?.get("model-viewer")) {\n modelViewerLoadPromise = Promise.resolve();\n return modelViewerLoadPromise;\n }\n modelViewerLoadPromise = new Promise<void>((resolve, reject) => {\n const script = document.createElement("script");\n script.type = "module";\n script.src = MODEL_VIEWER_CDN;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error("Failed to load model-viewer"));\n document.head.appendChild(script);\n });\n return modelViewerLoadPromise;\n}\n\n/**\n * ProductModel3D \u2014 interactive 3D product viewer wrapping Google\'s\n * `<model-viewer>` web component. Script loads on demand (one tag per page,\n * cached). AR mode auto-resolves the right OS surface: USDZ Quick Look on iOS,\n * Scene Viewer on Android, WebXR where available. Poster image shows during\n * load and as a fallback if the script can\'t load.\n */\nexport function ProductModel3D({\n src,\n iosSrc,\n poster,\n alt,\n aspectRatio = "square",\n ar = true,\n autoRotate = true,\n cameraControls = true,\n className,\n}: ProductModel3DProps): React.ReactElement {\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n let cancelled = false;\n ensureModelViewer().then(\n () => {\n if (!cancelled) setReady(true);\n },\n () => {\n // Stay on the poster fallback if the CDN script fails.\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <div\n data-cimplify-product-model-3d\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {ready\n ? React.createElement("model-viewer", {\n src,\n "ios-src": iosSrc,\n alt,\n poster,\n ar: ar || undefined,\n "ar-modes": ar ? "webxr scene-viewer quick-look" : undefined,\n "camera-controls": cameraControls || undefined,\n "auto-rotate": autoRotate || undefined,\n "shadow-intensity": "1",\n style: { width: "100%", height: "100%", backgroundColor: "transparent" },\n })\n : poster\n ? React.createElement("img", {\n src: poster,\n alt: alt ?? "",\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", objectFit: "cover" },\n })\n : React.createElement("div", {\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", backgroundColor: "var(--muted, #f3f4f6)" },\n })}\n </div>\n );\n}\n' }] }, "product-card": { "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 { 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 \u2014 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 \u2014 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 \u2192 explicit variant \u2192 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 \u2014 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 \u2014 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' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useEffect,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatWidgetStarter } from "../types/support";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName = "Support",\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n\n const [open, setOpen] = useState(defaultOpen);\n const [unread, setUnread] = useState(0);\n\n const { messages, isLoading, isSending, error, isActive, send, startConversation } =\n useChat({ client, starters, pollInterval });\n\n const prevCountRef = useRef(messages.length);\n\n // Track unread when panel is closed\n useEffect(() => {\n if (!open && messages.length > prevCountRef.current) {\n const newMessages = messages.slice(prevCountRef.current);\n const incomingCount = newMessages.filter(\n (m) => m.sender_type !== "customer",\n ).length;\n if (incomingCount > 0) setUnread((n) => n + incomingCount);\n }\n prevCountRef.current = messages.length;\n }, [messages, open]);\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n setUnread(0);\n }, []);\n\n const handleClose = useCallback(() => setOpen(false), []);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n error={error}\n isActive={isActive}\n isLeft={isLeft}\n onClose={handleClose}\n onSend={send}\n onStartConversation={startConversation}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-red-500 text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n error,\n isActive,\n isLeft,\n onClose,\n onSend,\n onStartConversation,\n classNames,\n}: {\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n onClose: () => void;\n onSend: (content: string) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n className={classNames?.messages}\n />\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-red-200 bg-red-50 px-4 py-2 text-xs text-red-600">\n Something went wrong. Please try again.\n </div>\n )}\n\n {/* Input \u2014 shown once conversation is active or loading */}\n {(isActive || isLoading) && (\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={onSend}\n className={classNames?.input}\n />\n )}\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n onClose,\n className,\n}: {\n businessName: string;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-white/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n <div className="flex items-center gap-1.5 text-xs text-white/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction MessageList({\n messages,\n isLoading,\n isSending,\n className,\n}: {\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n className?: string;\n}) {\n const bottomRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n bottomRef.current?.scrollIntoView({ behavior: "smooth" });\n }, [messages.length, isSending]);\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col gap-1 overflow-y-auto bg-muted/50 px-5 py-5",\n className,\n )}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {messages.map((msg, i) => {\n const isCustomer = msg.sender_type === "customer";\n const prev = messages[i - 1];\n const sameSenderAsPrev = prev?.sender_type === msg.sender_type;\n const isOptimistic = msg.id.startsWith("opt_");\n\n return (\n <div key={msg.id}>\n {!sameSenderAsPrev && i > 0 && <div className="h-3" />}\n <div\n className={cn(\n "flex max-w-[85%]",\n isCustomer ? "ml-auto" : "mr-auto",\n )}\n >\n <div\n className={cn(\n "rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",\n isCustomer\n ? "bg-foreground text-background"\n : "border border-border bg-background text-foreground",\n isCustomer\n ? sameSenderAsPrev\n ? "rounded-tr"\n : "rounded-br"\n : sameSenderAsPrev\n ? "rounded-tl"\n : "rounded-bl",\n isOptimistic && "opacity-70",\n )}\n >\n {!isCustomer && !sameSenderAsPrev && (\n <span className="mb-1 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-600">\n <SparkleIcon className="h-[11px] w-[11px]" />\n AI\n </span>\n )}\n <div className="whitespace-pre-wrap">{msg.content}</div>\n </div>\n </div>\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n <div ref={bottomRef} />\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSend = useCallback(() => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n onSend(trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction SparkleIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2.5}\n >\n <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />\n </svg>\n );\n}\n' }] }, "order-history": { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-history.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { useOrders } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryClassNames {\n root?: string;\n item?: string;\n orderId?: string;\n status?: string;\n date?: string;\n total?: string;\n empty?: string;\n loading?: string;\n reorderButton?: string;\n}\n\nexport interface OrderHistoryProps {\n /** Override orders (skips fetch). For SSR, pass pre-fetched orders. */\n orders?: Order[];\n /** Filter by status. */\n status?: OrderStatus;\n /** Max orders to display. */\n limit?: number;\n /** Called when an order row is clicked. */\n onOrderClick?: (order: Order) => void;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n /** Called when the reorder button is clicked. If provided, a reorder button is rendered. */\n onReorder?: (order: Order) => void;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: OrderHistoryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\n/**\n * OrderHistory \u2014 list of past orders with status and totals.\n *\n * Fetches via `useOrders` unless pre-loaded orders are passed.\n */\nexport function OrderHistory({\n orders: ordersProp,\n status,\n limit,\n onOrderClick,\n renderOrder,\n onReorder,\n emptyMessage = "No orders yet",\n className,\n classNames,\n}: OrderHistoryProps): React.ReactElement {\n const { orders: fetched, isLoading } = useOrders({\n status,\n limit,\n enabled: ordersProp === undefined,\n });\n\n const orders = ordersProp ?? fetched;\n\n if (isLoading && orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history className={cn(className, classNames?.root)}>\n {orders.map((order) => (\n <button\n key={order.id}\n type="button"\n onClick={() => onOrderClick?.(order)}\n data-cimplify-order-history-item\n data-status={order.status}\n className={classNames?.item}\n >\n {renderOrder ? (\n renderOrder(order)\n ) : (\n <>\n <div data-cimplify-order-history-main>\n <span data-cimplify-order-history-id className={classNames?.orderId}>\n #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-history-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n </div>\n <div data-cimplify-order-history-details>\n <time\n data-cimplify-order-history-date\n dateTime={order.created_at}\n className={classNames?.date}\n >\n {new Date(order.created_at).toLocaleDateString()}\n </time>\n <span data-cimplify-order-history-items>\n {order.total_quantity} {order.total_quantity === 1 ? "item" : "items"}\n </span>\n <span data-cimplify-order-history-total className={classNames?.total}>\n <Price amount={order.total_price} />\n </span>\n </div>\n {onReorder && (\n <button\n type="button"\n onClick={(e) => {\n e.stopPropagation();\n onReorder(order);\n }}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "wholesale-product-card": { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"], "files": [{ "path": "cards/wholesale-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* MOQ badge */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-zinc-800 text-white px-2 py-0.5 rounded-md">\n MOQ: {product.min_order_quantity}\n </span>\n )}\n\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n {/* SKU */}\n {product.sku && (\n <p className="text-[10px] text-muted-foreground font-mono">\n {product.sku}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price range or single */}\n <div className="mt-2">\n {hasTiers ? (\n <PriceRange product={product} className="text-sm font-bold" />\n ) : (\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n )}\n </div>\n\n {/* Stock */}\n {status && (\n <div className="flex items-center gap-1.5 mt-2">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock\n ? status.low_stock ? "bg-amber-500" : "bg-emerald-500"\n : "bg-red-500",\n )} />\n <span className={cn(\n "text-[10.5px] font-medium",\n status.in_stock\n ? status.low_stock ? "text-amber-600" : "text-emerald-600"\n : "text-red-600",\n )}>\n {!status.in_stock\n ? "Out of stock"\n : status.stock_level != null\n ? `${status.stock_level.toLocaleString()} in stock`\n : "In stock"\n }\n </span>\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "standard-service-card": { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/standard-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ServiceCardLayoutProps extends CardLayoutProps {\n slots?: AvailableSlot[];\n onBook?: (product: Product) => void;\n}\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr${h > 1 ? "s" : ""}`;\n }\n return `${minutes} min`;\n}\n\nexport function StandardServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Duration pill */}\n {product.duration_minutes != null && (\n <span className="absolute bottom-3 left-3 inline-flex items-center gap-1 text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[12px] text-muted-foreground">\n <span className="w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse" />\n <span>Available</span>\n {hasDeposit && (\n <>\n <span className="text-border">\xB7</span>\n <span><Price amount={product.deposit_amount!} currency={currency} /> deposit</span>\n </>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "volume-pricing": { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "volume-pricing.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { QuantityPricingTier } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n currency?: CurrencyCode;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}\u2013${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n currency,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn("border border-border", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n "flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox="0 0 12 12"\n fill="none"\n aria-hidden="true"\n className={cn(\n "w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180",\n classNames?.triggerIcon,\n )}\n >\n <path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn("border-t border-border divide-y divide-border", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n "flex items-center justify-between px-4 py-2.5 text-sm",\n active\n ? cn("bg-primary/5 font-medium", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn("text-muted-foreground", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} currency={currency} prefix="" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license_key: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_grant: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n redemption_code: { label: "Code", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "digital-product-layout": { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/digital-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst DIGITAL_TYPE_LABELS: Record<string, string> = {\n download: "Digital download",\n license_key: "Software license",\n ticket: "Event ticket",\n access_grant: "Access pass",\n redemption_code: "Redemption code",\n};\n\nexport function DigitalProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const typeLabel = product.digital_type\n ? DIGITAL_TYPE_LABELS[product.digital_type] || product.digital_type\n : "Digital product";\n\n const isTicket = product.digital_type === "ticket";\n const isAccessPass = product.digital_type === "access_grant";\n const isDownload = product.digital_type === "download" || product.digital_type === "license_key";\n\n return (\n <div data-cimplify-product-layout="digital" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Type badge + tags */}\n <div className="flex flex-wrap items-center gap-2">\n <span data-cimplify-product-layout-badge className="text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-1 rounded-full">\n {typeLabel}\n </span>\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold mt-2 block" />\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Delivery notice */}\n {isDownload && (\n <div data-cimplify-product-layout-delivery className="flex items-center gap-3 px-4 py-3 bg-muted rounded-lg">\n <svg className="w-5 h-5 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">\n <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <div>\n <p className="text-sm font-medium">Instant delivery</p>\n <p className="text-xs text-muted-foreground">Download immediately after purchase</p>\n </div>\n </div>\n )}\n\n {/* Event info */}\n {isTicket && (product.event_date || product.venue) && (\n <div data-cimplify-product-layout-event className="border border-border p-4 space-y-2">\n {product.event_date && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Date:</span>\n <span className="font-medium">{new Date(product.event_date).toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>\n </div>\n )}\n {product.venue && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Venue:</span>\n <span className="font-medium">{product.venue}</span>\n </div>\n )}\n {product.ticket_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Type:</span>\n <span className="font-medium capitalize">{product.ticket_type}</span>\n </div>\n )}\n </div>\n )}\n\n {/* Access pass info */}\n {isAccessPass && (product.access_type || product.access_duration_days) && (\n <div data-cimplify-product-layout-access className="border border-border p-4 space-y-2">\n {product.access_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Access:</span>\n <span className="font-medium capitalize">{product.access_type}</span>\n </div>\n )}\n {product.access_level && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Level:</span>\n <span className="font-medium capitalize">{product.access_level}</span>\n </div>\n )}\n {product.access_duration_days && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Duration:</span>\n <span className="font-medium">{product.access_duration_days} days</span>\n </div>\n )}\n </div>\n )}\n\n {/* File details */}\n {isDownload && (product.file_size_mb || product.file_type || product.version || product.max_downloads) && (\n <div data-cimplify-product-layout-file-details className="grid grid-cols-2 gap-3">\n {product.file_type && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Format</p>\n <p className="text-sm font-semibold">{product.file_type.toUpperCase()}</p>\n </div>\n )}\n {product.file_size_mb != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Size</p>\n <p className="text-sm font-semibold">{product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)} GB` : `${product.file_size_mb} MB`}</p>\n </div>\n )}\n {product.max_downloads != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Downloads</p>\n <p className="text-sm font-semibold">{product.max_downloads}</p>\n </div>\n )}\n {product.version && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Version</p>\n <p className="text-sm font-semibold">v{product.version}</p>\n </div>\n )}\n {product.download_expires_days != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Expires</p>\n <p className="text-sm font-semibold">{product.download_expires_days} days</p>\n </div>\n )}\n {product.max_activations != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Activations</p>\n <p className="text-sm font-semibold">{product.max_activations}</p>\n </div>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (license variants, billing plans, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="More digital products"\n />\n )}\n </div>\n );\n}\n' }] }, "slot-picker": { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "slot-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React, { useMemo } from "react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useAvailableSlots } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n slotScarcity?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID \u2014 used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) \u2014 used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `"multi_day"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Show a "N left" urgency tag when a slot\'s remaining capacity is at or below\n * this number (and above zero). Slots at zero capacity render as sold out.\n * Default: 3. Set to 0 to disable scarcity tags.\n */\n lowCapacityThreshold?: number;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /**\n * Hide slots whose `start_time` is already in the past. Default: true.\n * Set to false to show elapsed slots greyed-out (still un-selectable).\n */\n hideElapsedSlots?: boolean;\n /**\n * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n * start is sooner than `now + minLeadMinutes` are filtered out (when\n * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n */\n minLeadMinutes?: number;\n /**\n * Service scheduling mode. When `"multi_day"`, each slot renders as a\n * stay summary (`"3 nights: Fri Apr 5, 3:00 PM \u2192 Mon Apr 8, 11:00 AM"`)\n * instead of the time-of-day label. Defaults to `"intraday"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit \u2014 used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value \u2014 used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): "morning" | "afternoon" | "evening" {\n const hour = parseInt(timeStr.split("T").pop()?.split(":")[0] ?? timeStr.split(":")[0], 10);\n if (hour < 12) return "morning";\n if (hour < 17) return "afternoon";\n return "evening";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: "Morning",\n afternoon: "Afternoon",\n evening: "Evening",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return (["morning", "afternoon", "evening"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(":");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? "PM" : "AM";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? "day" : "days";\n const v = value ?? 1;\n if (unit === "minutes") return v === 1 ? "minute" : "minutes";\n if (unit === "hours") return v === 1 ? "hour" : "hours";\n if (unit === "days") return v === 1 ? "day" : "days";\n if (unit === "weeks") return v === 1 ? "week" : "weeks";\n if (unit === "months") return v === 1 ? "month" : "months";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} \u2192 ${endLabel}`;\n }\n return `${startLabel} \u2192 ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n lowCapacityThreshold = 3,\n currency,\n schedulingMode = "intraday",\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n emptyMessage = "No available slots",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === "multi_day";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const rawSlots = slotsProp ?? fetched;\n // Drop slots that have already elapsed (or fall within the lead-time\n // window). Default behaviour because nothing the merchant can do at\n // the backend stops a client clock from being slightly ahead of the\n // last availability response \u2014 this is the same defence other\n // booking flows ship by default.\n const slots = useMemo(() => {\n if (!hideElapsedSlots) return rawSlots;\n const cutoff = Date.now() + minLeadMinutes * 60_000;\n return rawSlots.filter((slot) => {\n const start = Date.parse(slot.start_time);\n return Number.isNaN(start) || start >= cutoff;\n });\n }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: "", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : "";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn("flex flex-col gap-4", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || "all"}\n data-cimplify-slot-group\n className={cn("flex flex-col gap-2", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n "text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? "flex flex-col gap-2"\n : "grid grid-cols-3 sm:grid-cols-4 gap-2",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n const capacity = slot.capacity_available ?? slot.remaining_capacity;\n const soldOut = capacity === 0;\n const unavailable = slot.is_available === false || soldOut;\n const lowCapacity =\n lowCapacityThreshold > 0 &&\n capacity != null &&\n capacity > 0 &&\n capacity <= lowCapacityThreshold;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={unavailable}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n "inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through",\n isMultiDay && "justify-between text-left",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn("text-xs opacity-70", classNames?.slotPrice)}\n >\n <Price amount={slot.price} currency={currency} />\n </span>\n )}\n {lowCapacity && (\n <span\n data-cimplify-slot-scarcity\n className={cn("text-xs font-medium text-amber-600", classNames?.slotScarcity)}\n >\n {capacity} left\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "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 \u2014 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 \u2014 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' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "rental-service-card": { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/rental-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n minutes: "min",\n hours: "hr",\n days: "day",\n nights: "night",\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function RentalServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "day";\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Per-unit pill */}\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n {/* Price */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />/{unit}\n </span>\n\n {/* Capacity badge */}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-blue-50/90 text-blue-700 border border-blue-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n Up to {product.general_service_capacity} guests\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Info pills */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} currency={currency} /> deposit\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {/* Availability */}\n {status && (\n <div className="flex items-center gap-1.5 text-[12px]">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock ? "bg-emerald-500" : "bg-red-500",\n )} />\n <span className={status.in_stock ? "text-muted-foreground" : "text-red-600"}>\n {status.in_stock\n ? status.stock_level != null\n ? `${status.stock_level} available`\n : "Available"\n : "Unavailable"\n }\n </span>\n </div>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Reserve now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "store-video": { "name": "store-video", "title": "StoreVideo", "description": "Autoplay-muted-loop video for storefront heroes / product demos. Lazy-loaded, mobile-safe (playsInline), poster fallback for browsers without video.", "type": "component", "registryDependencies": [], "files": [{ "path": "store-video.tsx", "content": '"use client";\n\nimport React, { useEffect, useRef, useState } from "react";\n\nexport interface StoreVideoProps {\n src: string;\n /** Poster shown before play + as the fallback if `<video>` isn\'t supported. */\n poster?: string;\n /** Accessible label; also the alt of the fallback `<img>`. */\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "16/10" | "3/4";\n /** Show native browser controls. Default off for autoplay-friendly hero clips. */\n controls?: boolean;\n /** Default `true` \u2014 muted is the only way mobile browsers allow autoplay. */\n autoplay?: boolean;\n loop?: boolean;\n muted?: boolean;\n /** Lazy-load: defer the src until the player scrolls into view. Default `true`. */\n lazy?: boolean;\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/9": { aspectRatio: "16/9" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\n/**\n * StoreVideo \u2014 `<video>` with mobile-safe defaults (muted + playsInline + loop),\n * IntersectionObserver-driven lazy load, and a poster `<img>` fallback for\n * browsers without video support. Drop-in replacement for a static hero image.\n */\nexport function StoreVideo({\n src,\n poster,\n alt,\n aspectRatio = "16/9",\n controls = false,\n autoplay = true,\n loop = true,\n muted = true,\n lazy = true,\n className,\n}: StoreVideoProps): React.ReactElement {\n const ref = useRef<HTMLVideoElement>(null);\n const [inView, setInView] = useState(!lazy);\n\n useEffect(() => {\n if (!lazy || inView) return;\n const node = ref.current;\n if (!node || typeof IntersectionObserver === "undefined") {\n setInView(true);\n return;\n }\n const observer = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n setInView(true);\n observer.disconnect();\n }\n },\n { rootMargin: "200px" },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, [lazy, inView]);\n\n return (\n <div\n data-cimplify-store-video\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n <video\n ref={ref}\n src={inView ? src : undefined}\n poster={poster}\n autoPlay={autoplay}\n loop={loop}\n muted={muted}\n playsInline\n controls={controls}\n preload={lazy ? "metadata" : "auto"}\n aria-label={alt}\n style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}\n >\n {poster ? (\n <img\n src={poster}\n alt={alt ?? ""}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n ) : null}\n </video>\n </div>\n );\n}\n' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "subscription-card": { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/subscription-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst FREQUENCY_LABELS: Record<string, string> = {\n weekly: "/week",\n biweekly: "/2wk",\n monthly: "/mo",\n quarterly: "/qtr",\n annually: "/yr",\n};\n\nexport function SubscriptionCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const plans = product.billing_plans || [];\n const primaryPlan = plans[0];\n const frequency = primaryPlan?.frequency\n ? FREQUENCY_LABELS[primaryPlan.frequency] || `/${primaryPlan.frequency}`\n : "/mo";\n const hasTrial = primaryPlan?.trial_days != null && primaryPlan.trial_days > 0;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />{frequency}\n </span>\n\n {/* Trial badge */}\n {hasTrial && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50/90 text-emerald-700 border border-emerald-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n {primaryPlan!.trial_days}-day free trial\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Tags */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 3).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Billing plan options */}\n {plans.length > 1 && (\n <div className="mt-3 space-y-1">\n {plans.slice(0, 3).map((plan) => {\n const label = plan.frequency\n ? plan.frequency.charAt(0).toUpperCase() + plan.frequency.slice(1)\n : "Standard";\n const hasMarkup = plan.markup_type && plan.markup_amount;\n const isSavings = plan.markup_type === "percentage" && plan.markup_amount != null && plan.markup_amount < 0;\n\n return (\n <div key={plan.id} className="flex items-center justify-between text-[11px]">\n <span className="text-muted-foreground">{label}</span>\n <span className="font-medium">\n {hasMarkup && isSavings && (\n <span className="text-emerald-600 mr-1">Save {Math.abs(plan.markup_amount!)}%</span>\n )}\n </span>\n </div>\n );\n })}\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {hasTrial && (\n <span className="text-emerald-600 font-medium">Free trial</span>\n )}\n {primaryPlan?.setup_fee != null && primaryPlan.setup_fee > 0 && (\n <span><Price amount={primaryPlan.setup_fee} currency={currency} /> setup</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Subscribe →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "product-customizer": { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"], "files": [{ "path": "product-customizer.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from "react";\nimport { Button } from "@base-ui/react/button";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { ProductBillingPlan } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice, getUnitPriceAtQuantity, getProductCurrency } from "@cimplify/sdk";\nimport { formatDuration } from "./utils/format-duration";\nimport { useCart, useQuote } from "@cimplify/sdk/react";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { VariantSelector } from "@cimplify/sdk/react";\nimport { AddOnSelector } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { BookingSummary } from "./booking-summary";\nimport type { Staff } from "@cimplify/sdk";\nimport { CompositeSelector } from "@cimplify/sdk/react";\nimport { BundleSelector } from "@cimplify/sdk/react";\nimport { BillingPlanSelector } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { CustomerInputFields } from "./customer-input-fields";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (\n variantId: string | undefined,\n variant: VariantView | undefined,\n ) => void;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState("");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n\n const cart = useCart();\n\n const currency = getProductCurrency(product);\n const productType = product.type || "product";\n const isComposite = productType === "composite";\n const isBundle = productType === "bundle";\n const isDigital = productType === "digital";\n const isService = productType === "service";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setCustomerInputValues({});\n setSpecialInstructions("");\n setSelectedSlot(null);\n setSelectedStaffId(null);\n }, [product.id, product.min_order_quantity]);\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== "" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || "", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n }\n : {}),\n customerInputs: Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== "")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, customerInputValues, specialInstructions, isService, selectedSlot, selectedStaffId, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn("space-y-6", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n currency={currency}\n />\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className="block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className="flex flex-wrap gap-2"\n classNames={{\n option:\n "inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background",\n avatar: "w-6 h-6 rounded-full object-cover",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== "none" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn("text-sm text-muted-foreground", classNames?.depositInfo)}>\n {product.deposit_type === "fixed" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} currency={currency} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn("flex flex-wrap gap-1.5", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className="inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn("text-sm text-muted-foreground", classNames?.duration)}>\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder="Special instructions (e.g., no onions, extra sauce)"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn("pt-4 border-t border-border", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id="cimplify-customizer-validation"\n data-cimplify-customizer-validation\n className={cn("text-sm text-destructive mb-3", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className="flex items-center gap-4">\n <div className="flex flex-col gap-1">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className="text-xs text-muted-foreground text-center">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className="text-sm text-muted-foreground text-right shrink-0">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} currency={currency} className="font-medium text-foreground" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? "cimplify-customizer-validation" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n "flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? "Added to Cart" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n' }] }, "booking-page": { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"], "files": [{ "path": "booking-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport type { Service, Staff, AvailableSlot, CustomerBooking } from "@cimplify/sdk";\nimport type { Resource } from "./resource-picker";\nimport { useCart } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { ResourcePicker } from "./resource-picker";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "./utils/format-duration";\nimport { parsePrice } from "@cimplify/sdk";\n\nexport interface BookingPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n serviceInfo?: string;\n step?: string;\n stepTitle?: string;\n summary?: string;\n summaryRow?: string;\n confirmButton?: string;\n backButton?: string;\n error?: string;\n rescheduleInfo?: string;\n depositInfo?: string;\n cancellationPolicy?: string;\n resourceStep?: string;\n}\n\nexport interface BookingPageProps {\n /** The service being booked. */\n service: Service;\n /** The underlying product (for deposit/cancellation fields). Falls back to service fields. */\n product?: Product;\n /** Optional staff list for staff selection step. */\n staff?: Staff[];\n /** Optional resources for resource selection step (rooms, equipment, etc.). */\n resources?: Resource[];\n /** Number of participants. */\n participantCount?: number;\n /** Page title. */\n title?: string;\n /** Called after successfully adding to cart. */\n onBooked?: (slot: AvailableSlot, staffId: string | null) => void;\n /** Called when user wants to go back. */\n onBack?: () => void;\n /** Existing booking for reschedule mode. */\n existingBooking?: CustomerBooking;\n /** Called after a successful reschedule. */\n onRescheduled?: (booking: CustomerBooking, newSlot: AvailableSlot) => void;\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n className?: string;\n classNames?: BookingPageClassNames;\n}\n\nconst STEP = {\n SELECT_SLOT: "select-slot",\n SELECT_RESOURCE: "select-resource",\n SELECT_STAFF: "select-staff",\n CONFIRM: "confirm",\n} as const;\n\ntype BookingStep = (typeof STEP)[keyof typeof STEP];\n\nexport function BookingPage({\n service,\n product,\n staff,\n resources,\n participantCount,\n title,\n onBooked,\n onBack,\n existingBooking,\n onRescheduled,\n showPrice = true,\n className,\n classNames,\n}: BookingPageProps): React.ReactElement {\n const { addItem } = useCart();\n const { client } = useCimplifyClient();\n\n const currency = service.currency as CurrencyCode | undefined;\n\n const isReschedule = existingBooking !== undefined;\n\n const [step, setStep] = useState<BookingStep>(STEP.SELECT_SLOT);\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedDate, setSelectedDate] = useState<string | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const hasResourceStep = resources && resources.length > 0;\n const hasStaffStep = staff && staff.length > 0;\n\n // Deposit info \u2014 prefer product fields, then fall back to service metadata\n const depositType = product?.deposit_type;\n const depositAmount = product?.deposit_amount;\n const hasDeposit = depositType !== undefined && depositType !== "none" && depositAmount !== undefined && parsePrice(depositAmount) !== 0;\n\n // Cancellation policy\n const cancellationMinutes = product?.cancellation_window_minutes;\n const noShowFee = product?.no_show_fee;\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot, date: string) => {\n setSelectedSlot(slot);\n setSelectedDate(date);\n setError(null);\n if (hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasResourceStep, hasStaffStep],\n );\n\n const handleResourceSelect = useCallback(\n (resourceId: string | null) => {\n setSelectedResourceId(resourceId);\n if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasStaffStep],\n );\n\n const handleStaffSelect = useCallback((staffId: string | null) => {\n setSelectedStaffId(staffId);\n setStep(STEP.CONFIRM);\n }, []);\n\n const handleBack = useCallback(() => {\n setError(null);\n if (step === STEP.CONFIRM && hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else if (step === STEP.CONFIRM && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.SELECT_STAFF && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.CONFIRM || step === STEP.SELECT_STAFF || step === STEP.SELECT_RESOURCE) {\n setStep(STEP.SELECT_SLOT);\n } else {\n onBack?.();\n }\n }, [step, hasStaffStep, hasResourceStep, onBack]);\n\n const handleConfirm = useCallback(async () => {\n if (!selectedSlot || !selectedDate) return;\n\n setIsSubmitting(true);\n setError(null);\n\n try {\n if (isReschedule && existingBooking) {\n // Reschedule mode \u2014 call the scheduling API instead of adding to cart\n const serviceItem = existingBooking.service_items[0];\n const result = await client.scheduling.rescheduleBooking({\n order_id: existingBooking.order_id,\n line_item_id: serviceItem?.service_id ?? existingBooking.order_id,\n new_start_time: selectedSlot.start_time,\n new_end_time: selectedSlot.end_time,\n new_staff_id: selectedStaffId || undefined,\n reschedule_type: "customer",\n });\n\n if (!result.ok) {\n throw result.error;\n }\n\n onRescheduled?.(existingBooking, selectedSlot);\n } else {\n // Normal booking mode \u2014 add to cart\n const serviceProduct = {\n id: service.product_id || service.id,\n business_id: service.business_id || "",\n category_id: service.category_id || undefined,\n name: service.name,\n slug: service.id,\n description: service.description || undefined,\n image_url: service.image_url || undefined,\n default_price: (service.price || "0") as Product["default_price"],\n type: "service" as const,\n inventory_type: "none" as const,\n variant_strategy: "fetch_all" as const,\n is_active: service.is_available,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString(),\n };\n\n await addItem(serviceProduct, 1, {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId || undefined,\n resourceId: selectedResourceId || undefined,\n });\n\n onBooked?.(selectedSlot, selectedStaffId);\n }\n } catch (err) {\n const fallbackMessage = isReschedule ? "Failed to reschedule booking" : "Failed to add booking to cart";\n setError(err instanceof Error ? err.message : fallbackMessage);\n } finally {\n setIsSubmitting(false);\n }\n }, [selectedSlot, selectedDate, selectedStaffId, service, addItem, onBooked, isReschedule, existingBooking, client, onRescheduled]);\n\n return (\n <div data-cimplify-booking-page className={cn(className, classNames?.root)}>\n <div data-cimplify-booking-page-header className={classNames?.header}>\n {onBack && step === STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n {step !== STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n <h1 data-cimplify-booking-page-title className={classNames?.title}>\n {title || (isReschedule ? "Reschedule Booking" : `Book ${service.name}`)}\n </h1>\n </div>\n\n {isReschedule && existingBooking && (\n <div data-cimplify-booking-reschedule-info className={classNames?.rescheduleInfo}>\n <span>Rescheduling booking from {new Date(existingBooking.service_items[0]?.scheduled_start ?? existingBooking.created_at).toLocaleDateString()}</span>\n </div>\n )}\n\n <div data-cimplify-booking-service-info className={classNames?.serviceInfo}>\n <span data-cimplify-booking-service-name>{service.name}</span>\n <span data-cimplify-booking-service-duration>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n {service.price && (\n <span data-cimplify-booking-service-price>\n <Price amount={service.price} currency={currency} />\n </span>\n )}\n {hasDeposit && (\n <span data-cimplify-booking-deposit-info className={classNames?.depositInfo}>\n Deposit: {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} currency={currency} />}\n </span>\n )}\n {cancellationMinutes !== undefined && cancellationMinutes > 0 && (\n <span data-cimplify-booking-cancellation-policy className={classNames?.cancellationPolicy}>\n Free cancellation up to {cancellationMinutes >= 60 ? `${Math.floor(cancellationMinutes / 60)} hour${Math.floor(cancellationMinutes / 60) !== 1 ? "s" : ""}` : `${cancellationMinutes} minute${cancellationMinutes !== 1 ? "s" : ""}`} before\n </span>\n )}\n {noShowFee !== undefined && parsePrice(noShowFee) !== 0 && (\n <span data-cimplify-booking-no-show-fee className={classNames?.cancellationPolicy}>\n No-show fee: <Price amount={noShowFee} currency={currency} />\n </span>\n )}\n </div>\n\n {error && (\n <div data-cimplify-booking-error className={classNames?.error}>\n {error}\n </div>\n )}\n\n {step === STEP.SELECT_SLOT && (\n <div data-cimplify-booking-step="select-slot" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Select a date & time\n </h2>\n <DateSlotPicker\n serviceId={service.id}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n currency={currency}\n />\n </div>\n )}\n\n {step === STEP.SELECT_RESOURCE && resources && (\n <div data-cimplify-booking-step="select-resource" className={cn(classNames?.step, classNames?.resourceStep)}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a room\n </h2>\n <ResourcePicker\n resources={resources}\n selectedResourceId={selectedResourceId}\n onResourceSelect={handleResourceSelect}\n />\n </div>\n )}\n\n {step === STEP.SELECT_STAFF && staff && (\n <div data-cimplify-booking-step="select-staff" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a provider\n </h2>\n <StaffPicker\n staff={staff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={handleStaffSelect}\n />\n </div>\n )}\n\n {step === STEP.CONFIRM && selectedSlot && (\n <div data-cimplify-booking-step="confirm" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Confirm your booking\n </h2>\n <div data-cimplify-booking-summary className={classNames?.summary}>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Service</span>\n <span>{service.name}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Date</span>\n <span>{selectedDate}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Time</span>\n <span>\n {new Date(selectedSlot.start_time).toLocaleTimeString(undefined, {\n hour: "numeric",\n minute: "2-digit",\n })}\n </span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Duration</span>\n <span>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n </div>\n {selectedResourceId && resources && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Room</span>\n <span>{resources.find((r) => r.id === selectedResourceId)?.name ?? "Selected"}</span>\n </div>\n )}\n {selectedStaffId && staff && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Provider</span>\n <span>{staff.find((s) => s.id === selectedStaffId)?.name ?? "Selected"}</span>\n </div>\n )}\n {(selectedSlot.price || service.price) && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Price</span>\n <span>\n <Price amount={selectedSlot.price || service.price!} currency={currency} />\n </span>\n </div>\n )}\n {hasDeposit && (\n <div data-cimplify-booking-summary-row className={cn(classNames?.summaryRow, classNames?.depositInfo)}>\n <span>Deposit</span>\n <span>\n {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} currency={currency} />}\n </span>\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={handleConfirm}\n disabled={isSubmitting}\n data-cimplify-booking-confirm\n className={classNames?.confirmButton}\n >\n {isSubmitting\n ? (isReschedule ? "Rescheduling\u2026" : "Adding to cart\u2026")\n : (isReschedule ? "Reschedule" : "Confirm Booking")}\n </button>\n </div>\n )}\n </div>\n );\n}\n' }] }, "price": { "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [], "files": [{ "path": "price.tsx", "content": '"use client";\n\nimport React from "react";\nimport { formatPrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceProps {\n /** The amount, denominated in `currency` (or the business base currency). */\n amount: number | string;\n /**\n * Currency the `amount` is denominated in. Pass the product/order currency\n * so the value renders correctly during SSR \u2014 before the provider has\n * loaded the business client-side. Falls back to the provider\'s base\n * currency, then USD.\n */\n currency?: CurrencyCode;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. "+"). */\n prefix?: string;\n /**\n * Apply the provider\'s FX display-currency override when one is active.\n * Default true. The conversion is a post-hydration enhancement: it only\n * engages once an FX rate has loaded client-side, so SSR and first-paint\n * output always match and no currency flash occurs.\n */\n convert?: boolean;\n}\n\n/**\n * Price \u2014 renders a formatted price value.\n *\n * Currency is resolved from the `currency` prop (the amount\'s source currency),\n * falling back to the provider\'s base currency, then USD. Because the source\n * currency comes from data rather than the client-only provider bootstrap, an\n * explicit `currency` renders correctly during SSR.\n *\n * FX display-currency conversion (when a user picks a non-base display\n * currency) is layered on top after hydration, and only when the source is the\n * base currency the FX rate is computed from. It never affects the server or\n * first-paint render, so there is no hydration mismatch.\n */\nexport function Price({\n amount,\n currency,\n className,\n prefix,\n convert = true,\n}: PriceProps): React.ReactElement {\n const context = useOptionalCimplify();\n const sourceCurrency =\n currency ?? (context?.baseCurrency as CurrencyCode | undefined) ?? "USD";\n\n const fxActive =\n convert &&\n context?.fxRate != null &&\n context.displayCurrency !== sourceCurrency &&\n sourceCurrency === context.baseCurrency;\n\n const resolvedCurrency = (fxActive ? context!.displayCurrency : sourceCurrency) as CurrencyCode;\n const resolvedAmount = fxActive\n ? context!.convertPrice(amount)\n : typeof amount === "string"\n ? parseFloat(amount) || 0\n : amount;\n\n return (\n <span className={className}>\n {prefix}\n {formatPrice(resolvedAmount, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "recently-viewed": { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recently-viewed.tsx", "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 \u2014 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' }] }, "category-filter": { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-filter.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryFilterClassNames {\n root?: string;\n item?: string;\n allButton?: string;\n count?: string;\n}\n\nexport interface CategoryFilterProps {\n /** Currently selected category ID. Null means "all". */\n selectedId?: string | null;\n /** Called when a category is selected. Null means "all". */\n onSelect: (categoryId: string | null) => void;\n /** Label for the "all" option. Default: "All". */\n allLabel?: string;\n /** Show product counts per category. Default: true. */\n showCounts?: boolean;\n className?: string;\n classNames?: CategoryFilterClassNames;\n}\n\n/** Sentinel value representing the "all" tab (no category filter). */\nconst ALL_VALUE = "__all__";\n\n/**\n * CategoryFilter \u2014 horizontal or vertical list of category chips.\n *\n * Fetches categories via `useCategories` and renders selectable buttons.\n * The parent controls selection state via `selectedId` + `onSelect`.\n *\n * Built on Base UI Tabs for accessible keyboard navigation and ARIA roles.\n */\nexport function CategoryFilter({\n selectedId = null,\n onSelect,\n allLabel = "All",\n showCounts = true,\n className,\n classNames,\n}: CategoryFilterProps): React.ReactElement {\n const { categories, isLoading } = useCategories();\n\n const handleValueChange = useCallback(\n (value: string | number | null) => {\n onSelect(value === ALL_VALUE ? null : String(value));\n },\n [onSelect],\n );\n\n if (isLoading) {\n return (\n <div\n data-cimplify-category-filter\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n return (\n <Tabs.Root\n value={selectedId ?? ALL_VALUE}\n onValueChange={handleValueChange}\n >\n <Tabs.List\n data-cimplify-category-filter\n aria-label="Filter by category"\n className={cn(className, classNames?.root)}\n >\n <Tabs.Tab\n value={ALL_VALUE}\n data-cimplify-category-filter-item\n data-selected={selectedId === null || undefined}\n className={cn(classNames?.item, classNames?.allButton)}\n >\n {allLabel}\n </Tabs.Tab>\n\n {categories.map((category: Category) => (\n <Tabs.Tab\n key={category.id}\n value={category.id}\n data-cimplify-category-filter-item\n data-selected={selectedId === category.id || undefined}\n className={classNames?.item}\n >\n {category.name}\n {showCounts && category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count}\n </span>\n )}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n );\n}\n' }] }, "store-nav": { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "store-nav.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart, useCategories } from "@cimplify/sdk/react";\nimport type { Category } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface StoreNavClassNames {\n root?: string;\n brand?: string;\n categories?: string;\n categoryLink?: string;\n actions?: string;\n cartButton?: string;\n cartCount?: string;\n searchButton?: string;\n}\n\nexport interface StoreNavProps {\n /** Store/brand name. */\n storeName?: string;\n /** Custom brand element (logo, etc.). Overrides storeName. */\n renderBrand?: () => React.ReactNode;\n /** Override categories (skips fetch). */\n categories?: Category[];\n /** Called when a category link is clicked. */\n onCategoryClick?: (category: Category) => void;\n /** Called when the cart button is clicked. */\n onCartClick?: () => void;\n /** Called when the search button is clicked. */\n onSearchClick?: () => void;\n /** Hide category navigation. */\n hideCategories?: boolean;\n /** Hide the cart button. */\n hideCart?: boolean;\n /** Hide the search button. */\n hideSearch?: boolean;\n className?: string;\n classNames?: StoreNavClassNames;\n}\n\n/**\n * StoreNav \u2014 top navigation bar with brand, category links, cart badge, and search.\n *\n * Fetches categories via `useCategories` and cart count via `useCart`.\n * Renders as a semantic `<nav>` element.\n */\nexport function StoreNav({\n storeName,\n renderBrand,\n categories: categoriesProp,\n onCategoryClick,\n onCartClick,\n onSearchClick,\n hideCategories = false,\n hideCart = false,\n hideSearch = false,\n className,\n classNames,\n}: StoreNavProps): React.ReactElement {\n const { categories: fetched } = useCategories({\n enabled: !hideCategories && categoriesProp === undefined,\n });\n const { itemCount } = useCart();\n\n const categories = categoriesProp ?? fetched;\n\n return (\n <nav data-cimplify-store-nav className={cn(className, classNames?.root)}>\n {/* Brand */}\n <div data-cimplify-store-nav-brand className={classNames?.brand}>\n {renderBrand ? renderBrand() : storeName && <span>{storeName}</span>}\n </div>\n\n {/* Category links */}\n {!hideCategories && categories.length > 0 && (\n <div data-cimplify-store-nav-categories className={classNames?.categories}>\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onCategoryClick?.(category)}\n data-cimplify-store-nav-category\n className={classNames?.categoryLink}\n >\n {category.name}\n </button>\n ))}\n </div>\n )}\n\n {/* Actions */}\n <div data-cimplify-store-nav-actions className={classNames?.actions}>\n {!hideSearch && (\n <button\n type="button"\n onClick={onSearchClick}\n data-cimplify-store-nav-search\n className={classNames?.searchButton}\n aria-label="Search"\n >\n Search\n </button>\n )}\n\n {!hideCart && (\n <button\n type="button"\n onClick={onCartClick}\n data-cimplify-store-nav-cart\n className={classNames?.cartButton}\n aria-label={`Cart (${itemCount} items)`}\n >\n Cart\n {itemCount > 0 && (\n <span data-cimplify-store-nav-cart-count className={classNames?.cartCount}>\n {itemCount}\n </span>\n )}\n </button>\n )}\n </div>\n </nav>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} currency={currency} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} currency={currency} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} currency={currency} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "location-picker": { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "location-picker.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Location } from "../types/business";\nimport { useLocations } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface LocationPickerClassNames {\n root?: string;\n item?: string;\n activeItem?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface LocationPickerProps {\n /** Override locations (skips useLocations fetch). For SSR, pass pre-fetched locations. */\n locations?: Location[];\n /** Override the current location. */\n currentLocation?: Location | null;\n /** Called when a location is selected. */\n onLocationChange?: (location: Location) => void;\n /** Custom location renderer. */\n renderLocation?: (location: Location, isActive: boolean) => React.ReactNode;\n /** Text shown when no locations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: LocationPickerClassNames;\n}\n\n/**\n * LocationPicker \u2014 renders a location selector for multi-location businesses.\n *\n * Uses `useLocations` to fetch locations unless overridden via props.\n * Returns `null` when there is only one location (no picker needed).\n */\nexport function LocationPicker({\n locations: locationsProp,\n currentLocation: currentLocationProp,\n onLocationChange,\n renderLocation,\n emptyMessage,\n className,\n classNames,\n}: LocationPickerProps): React.ReactElement | null {\n const {\n locations: fetched,\n currentLocation: fetchedCurrent,\n setCurrentLocation,\n isLoading,\n } = useLocations();\n\n const locations = locationsProp ?? fetched;\n const currentLocation = currentLocationProp ?? fetchedCurrent;\n\n const handleSelect = (location: Location): void => {\n if (onLocationChange) {\n onLocationChange(location);\n } else {\n setCurrentLocation(location);\n }\n };\n\n if (isLoading && locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n // No picker needed for single location\n if (locations.length <= 1) {\n return null;\n }\n\n if (locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No locations available"}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-location-picker className={cn(className, classNames?.root)}>\n {locations.map((location) => {\n const isActive = currentLocation?.id === location.id;\n return (\n <button\n key={location.id}\n type="button"\n onClick={() => handleSelect(location)}\n data-cimplify-location-item\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.item, isActive && classNames?.activeItem)}\n >\n {renderLocation ? (\n renderLocation(location, isActive)\n ) : (\n <span>{location.name}</span>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "rich-text": { "name": "rich-text", "title": "RichText", "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "rich-text.tsx", "content": '"use client";\n\nimport React, { useMemo } from "react";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from "xss";\nimport { cn } from "@cimplify/sdk/react";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n "www.youtube.com",\n "youtube.com",\n "www.youtube-nocookie.com",\n "youtube-nocookie.com",\n "player.vimeo.com",\n]);\n\n// Fixed permission policy for embeds. Merchant-supplied `allow` is discarded\n// and replaced with this \u2014 a description must never request camera/mic/etc.\nconst IFRAME_ALLOW =\n "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen";\n\n// `class` is an unrestricted CSS channel in a Tailwind storefront \u2014 a merchant\n// class like `fixed inset-0 z-50` re-enables exactly what the inline-CSS filter\n// below forbids. So `class` values are filtered to this namespace: only the\n// SDK\'s own `cimplify-*` layout primitives survive; everything else is dropped.\nconst SAFE_CLASS_PATTERN = /^cimplify-[a-z0-9-]+$/i;\n\n// Inline CSS properties we permit (via the xss css filter). Anything else \u2014\n// position, z-index, behaviours, url() exfil vectors \u2014 is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n "text-align": true,\n color: true,\n "background-color": true,\n "font-weight": true,\n "font-style": true,\n "text-decoration": true,\n "vertical-align": true,\n width: true,\n height: true,\n};\n\nconst STYLEABLE = ["style", "class"];\n\n/**\n * Allowlist covering rich product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n p: STYLEABLE,\n br: [],\n div: STYLEABLE,\n section: ["class"],\n span: STYLEABLE,\n strong: [],\n b: [],\n em: [],\n i: [],\n u: [],\n s: [],\n strike: [],\n del: [],\n ins: [],\n mark: [],\n small: [],\n sub: [],\n sup: [],\n h1: STYLEABLE,\n h2: STYLEABLE,\n h3: STYLEABLE,\n h4: STYLEABLE,\n h5: STYLEABLE,\n h6: STYLEABLE,\n ul: ["class"],\n ol: ["start", "type", "class"],\n li: ["class"],\n dl: ["class"],\n dt: ["class"],\n dd: ["class"],\n blockquote: ["class"],\n code: [],\n pre: ["class"],\n hr: [],\n a: ["href", "title", "target", "rel"],\n img: ["src", "alt", "title", "width", "height", "loading", "class", "style"],\n figure: ["class"],\n figcaption: ["class"],\n details: [],\n summary: [],\n table: ["class", "style"],\n thead: [],\n tbody: [],\n tfoot: [],\n caption: [],\n colgroup: [],\n col: ["span", "style"],\n tr: ["class"],\n th: ["colspan", "rowspan", "scope", "align", "class", "style"],\n td: ["colspan", "rowspan", "align", "class", "style"],\n iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "loading"],\n};\n\nfunction safeAttrValue(\n tag: string,\n name: string,\n value: string,\n cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n if (tag === "iframe" && name === "src") {\n try {\n const url = new URL(value);\n if (url.protocol === "https:" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n return value;\n }\n } catch {\n // malformed URL \u2014 fall through to blank\n }\n return "";\n }\n if (tag === "iframe" && name === "allow") {\n return IFRAME_ALLOW;\n }\n if (name === "class") {\n return value\n .split(/\\s+/)\n .filter((token) => SAFE_CLASS_PATTERN.test(token))\n .join(" ");\n }\n return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// Reverse-tabnabbing guard: force `rel="noopener noreferrer"` on any\n// `target="_blank"` anchor. Runs after FILTER.process, whose output has already\n// escaped attribute values \u2014 so no raw `>` can appear inside an attribute and a\n// tag-level regex is safe. Deterministic (identical SSR + client).\nfunction withLinkSafety(html: string): string {\n return html.replace(/<a\\b[^>]*>/gi, (tag) => {\n if (!/\\btarget\\s*=\\s*["\']_blank["\']/i.test(tag)) return tag;\n return /\\brel\\s*=\\s*["\'][^"\']*["\']/i.test(tag)\n ? tag.replace(/\\brel\\s*=\\s*["\'][^"\']*["\']/i, \'rel="noopener noreferrer"\')\n : tag.replace(/>$/, \' rel="noopener noreferrer">\');\n });\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n whiteList: RICH_TEXT_WHITELIST,\n css: { whiteList: ALLOWED_CSS_PROPERTIES },\n safeAttrValue,\n stripIgnoreTag: true,\n stripIgnoreTagBody: ["script", "style"],\n allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser \u2014 no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n return withLinkSafety(FILTER.process(html));\n}\n\n// Theme-aware prose styling. Wired to the consumer\'s own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront\'s type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n "text-muted-foreground leading-relaxed",\n "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",\n "[&_p]:my-3",\n "[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground",\n "[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground",\n "[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground",\n "[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground",\n "[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground",\n "[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground",\n "[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground",\n "[&_em]:italic [&_i]:italic",\n "[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through",\n "[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]",\n "[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",\n "[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1",\n "[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1",\n "[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]",\n "[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4",\n "[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic",\n "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",\n "[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]",\n "[&_pre>code]:bg-transparent [&_pre>code]:p-0",\n "[&_hr]:my-4 [&_hr]:border-border",\n "[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground",\n "[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md",\n "[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3",\n "[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground",\n "[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm",\n "[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground",\n "[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground",\n "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top",\n "[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md",\n);\n\nexport interface RichTextProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML" | "children"> {\n /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n html?: string | null;\n}\n\n/**\n * RichText \u2014 renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there\'s no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : ""), [html]);\n\n if (!clean) {\n return null;\n }\n\n return (\n <div\n data-cimplify-rich-text\n className={cn(RICH_TEXT_PROSE, className)}\n dangerouslySetInnerHTML={{ __html: clean }}\n {...rest}\n />\n );\n}\n' }] }, "sale-badge": { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "sale-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductDealInfo } from "@cimplify/sdk";\nimport type { ProductWithPrice } from "@cimplify/sdk";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n getProductCurrency,\n parsePrice,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge \u2014 shows a sale/discount indicator for a product.\n *\n * Returns `null` when there\'s no deal, no sale price difference, and no label override,\n * so it\'s safe to render unconditionally \u2014 it simply won\'t show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n // Percentage: prefer dealInfo when it\'s a percentage benefit, else compute from prices\n let percentage: number | null = null;\n if (hasDeal && dealInfo.benefit_type === "percentage") {\n percentage = parsePrice(dealInfo.value);\n } else if (onSale) {\n percentage = getDiscountPercentage(product);\n }\n\n // Badge text: explicit label > deal label > computed "X% off"\n const badgeText =\n label ??\n dealInfo?.label ??\n (percentage != null && percentage > 0 ? `${percentage}% off` : null);\n\n if (!badgeText) {\n return null;\n }\n\n return (\n <span\n data-cimplify-sale-badge\n className={cn(className, classNames?.root)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n {showPercentage && percentage != null && percentage > 0 && (\n <span data-cimplify-sale-percentage className={classNames?.percentage}>\n -{percentage}%\n </span>\n )}\n <span data-cimplify-sale-label className={classNames?.label}>\n {badgeText}\n </span>\n {showOriginalPrice && onSale && (\n <span data-cimplify-sale-original-price className={classNames?.originalPrice}>\n <Price amount={getBasePrice(product)} currency={getProductCurrency(product)} />\n </span>\n )}\n </span>\n );\n}\n' }] }, "booking-card": { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "booking-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking, BookingStatus } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingCardClassNames {\n root?: string;\n service?: string;\n status?: string;\n time?: string;\n confirmationCode?: string;\n total?: string;\n actions?: string;\n cancelButton?: string;\n rescheduleButton?: string;\n}\n\nexport interface BookingCardProps {\n /** The booking to display. */\n booking: CustomerBooking;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Custom renderer for the booking. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingCardClassNames;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n pending: "Pending",\n confirmed: "Confirmed",\n in_progress: "In Progress",\n completed: "Completed",\n cancelled: "Cancelled",\n no_show: "No Show",\n};\n\nfunction isActiveStatus(status: BookingStatus): boolean {\n const s = String(status).toLowerCase();\n return s !== "completed" && s !== "cancelled" && s !== "no_show";\n}\n\nfunction getFirstServiceItem(booking: CustomerBooking) {\n return booking.service_items[0] ?? null;\n}\n\nfunction formatScheduledTime(start?: string | null, end?: string | null): string | null {\n if (!start) return null;\n try {\n const startDate = new Date(start);\n const options: Intl.DateTimeFormatOptions = {\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n };\n let formatted = startDate.toLocaleString(undefined, options);\n if (end) {\n const endDate = new Date(end);\n formatted += ` \u2013 ${endDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;\n }\n return formatted;\n } catch {\n return start;\n }\n}\n\nexport function BookingCard({\n booking,\n onCancel,\n onReschedule,\n renderBooking,\n className,\n classNames,\n}: BookingCardProps): React.ReactElement {\n if (renderBooking) {\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(className, classNames?.root)}\n >\n {renderBooking(booking)}\n </div>\n );\n }\n\n const firstItem = getFirstServiceItem(booking);\n const active = isActiveStatus(booking.status);\n const scheduledTime = firstItem\n ? formatScheduledTime(firstItem.scheduled_start, firstItem.scheduled_end)\n : null;\n\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(\n "rounded-xl border border-border bg-background p-4 flex flex-col gap-3",\n className,\n classNames?.root,\n )}\n >\n <div data-cimplify-booking-main className="flex items-center justify-between gap-2">\n <span data-cimplify-booking-service className={cn("font-semibold text-foreground", classNames?.service)}>\n Booking #{booking.order_id.slice(0, 8)}\n </span>\n <span\n data-cimplify-booking-status\n data-status={booking.status}\n className={cn(\n "text-xs font-medium px-2 py-0.5 rounded-full",\n active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",\n classNames?.status,\n )}\n >\n {STATUS_LABELS[booking.status] ?? booking.status}\n </span>\n </div>\n\n {firstItem?.confirmation_code && (\n <span data-cimplify-booking-code className={cn("text-xs font-mono text-muted-foreground tracking-wide", classNames?.confirmationCode)}>\n {firstItem.confirmation_code}\n </span>\n )}\n\n {scheduledTime && (\n <time\n data-cimplify-booking-time\n dateTime={firstItem?.scheduled_start ?? undefined}\n className={cn("text-sm text-foreground", classNames?.time)}\n >\n {scheduledTime}\n </time>\n )}\n\n <span data-cimplify-booking-total className={cn("text-sm font-medium text-foreground", classNames?.total)}>\n <Price amount={booking.total_price} />\n </span>\n\n {active && (onCancel || onReschedule) && (\n <div data-cimplify-booking-actions className={cn("flex gap-2 pt-1 border-t border-border mt-1", classNames?.actions)}>\n {onReschedule && (\n <button\n type="button"\n onClick={() => onReschedule(booking)}\n data-cimplify-booking-reschedule\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted cursor-pointer",\n classNames?.rescheduleButton,\n )}\n >\n Reschedule\n </button>\n )}\n {onCancel && (\n <button\n type="button"\n onClick={() => onCancel(booking)}\n data-cimplify-booking-cancel\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-destructive transition-colors hover:bg-destructive/10 cursor-pointer",\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n )}\n </div>\n )}\n </div>\n );\n}\n' }] }, "discount-input": { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "discount-input.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport { useValidateDiscount } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DiscountInputClassNames {\n root?: string;\n input?: string;\n button?: string;\n result?: string;\n error?: string;\n success?: string;\n}\n\nexport interface DiscountInputProps {\n /** Current order subtotal for validation. */\n orderSubtotal: string;\n /** Location ID for location-specific discounts. */\n locationId?: string;\n /** Called when a valid discount is applied. */\n onApply?: (validation: DiscountValidation) => void;\n /** Called when discount is cleared. */\n onClear?: () => void;\n /** Placeholder text. */\n placeholder?: string;\n className?: string;\n classNames?: DiscountInputClassNames;\n}\n\n/**\n * DiscountInput \u2014 discount code input with inline validation.\n *\n * Wraps `useValidateDiscount` with a Base UI Field + Input and apply button.\n * Shows validation result inline (success with amount, or error via Field.Error).\n */\nexport function DiscountInput({\n orderSubtotal,\n locationId,\n onApply,\n onClear,\n placeholder = "Discount code",\n className,\n classNames,\n}: DiscountInputProps): React.ReactElement {\n const [code, setCode] = useState("");\n const [appliedValidation, setAppliedValidation] =\n useState<DiscountValidation | null>(null);\n const { validate, isValidating, error } = useValidateDiscount();\n\n const handleApply = useCallback(async () => {\n const trimmed = code.trim();\n if (!trimmed) return;\n\n const result = await validate(trimmed, orderSubtotal, locationId);\n if (result) {\n if (result.is_eligible) {\n setAppliedValidation(result);\n onApply?.(result);\n } else {\n setAppliedValidation(result);\n }\n }\n }, [code, validate, orderSubtotal, locationId, onApply]);\n\n const handleClear = useCallback(() => {\n setCode("");\n setAppliedValidation(null);\n onClear?.();\n }, [onClear]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === "Enter") {\n void handleApply();\n }\n },\n [handleApply],\n );\n\n const isApplied = appliedValidation?.is_eligible === true;\n const hasError =\n !!error || (!!appliedValidation && !appliedValidation.is_eligible);\n\n const errorMessage = error\n ? error.message\n : appliedValidation && !appliedValidation.is_eligible\n ? (appliedValidation.ineligibility_reason ?? "This code is not valid.")\n : undefined;\n\n return (\n <Field.Root\n data-cimplify-discount\n invalid={hasError}\n disabled={isApplied}\n className={cn(className, classNames?.root)}\n >\n <div data-cimplify-discount-form>\n <Input\n type="text"\n value={code}\n onValueChange={(value) => setCode(value)}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n data-cimplify-discount-input\n className={classNames?.input}\n aria-label="Discount code"\n />\n {isApplied ? (\n <button\n type="button"\n onClick={handleClear}\n data-cimplify-discount-clear\n className={classNames?.button}\n >\n Remove\n </button>\n ) : (\n <button\n type="button"\n onClick={handleApply}\n disabled={isValidating || code.trim().length === 0}\n data-cimplify-discount-apply\n className={classNames?.button}\n >\n {isValidating ? "Checking..." : "Apply"}\n </button>\n )}\n </div>\n\n {hasError && errorMessage && (\n <Field.Error\n match={true}\n data-cimplify-discount-error\n className={classNames?.error}\n >\n {errorMessage}\n </Field.Error>\n )}\n\n {isApplied && appliedValidation.discount_amount && (\n <Field.Description\n data-cimplify-discount-success\n className={classNames?.success}\n >\n <span>Discount applied</span>\n <Price amount={appliedValidation.discount_amount} prefix="-" />\n </Field.Description>\n )}\n </Field.Root>\n );\n}\n' }] }, "add-on-selector": { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "add-on-selector.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport type { AddOnWithOptions } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n currency?: CurrencyCode;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n currency,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const selectedSet = useMemo(() => new Set(selectedOptions), [selectedOptions]);\n\n const isOptionSelected = useCallback(\n (optionId: string) => selectedSet.has(optionId),\n [selectedSet],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedSet.has(optionId);\n\n if (addOn.is_mutually_exclusive || !addOn.is_multiple_allowed) {\n const groupOptionIds = new Set(addOn.options.map((o) => o.id));\n const withoutGroup = selectedOptions.filter((id) => !groupOptionIds.has(id));\n\n if (isSelected) {\n if (!addOn.is_required) {\n onOptionsChange(withoutGroup);\n }\n } else {\n onOptionsChange([...withoutGroup, optionId]);\n }\n } else {\n if (isSelected) {\n onOptionsChange(selectedOptions.filter((id) => id !== optionId));\n } else {\n let currentCount = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentCount += 1;\n }\n }\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, selectedSet, onOptionsChange],\n );\n\n if (!addOns || addOns.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-addon-selector className={cn("space-y-6", className, classNames?.root)}>\n {addOns.map((addOn) => {\n let currentSelections = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentSelections += 1;\n }\n }\n const minMet = !addOn.min_selections || currentSelections >= addOn.min_selections;\n const isSingleSelect = addOn.is_mutually_exclusive || !addOn.is_multiple_allowed;\n\n return (\n <div\n key={addOn.id}\n data-cimplify-addon-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-addon-header\n className={cn("flex items-center justify-between py-3", classNames?.header)}\n >\n <div>\n <span\n data-cimplify-addon-name\n className={cn("text-base font-bold", classNames?.name)}\n >\n {addOn.name}\n </span>\n {(addOn.min_selections || addOn.max_selections) && (\n <span\n data-cimplify-addon-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.constraint)}\n >\n {addOn.min_selections && addOn.max_selections\n ? `Choose ${addOn.min_selections}\\u2013${addOn.max_selections}`\n : addOn.min_selections\n ? `Choose at least ${addOn.min_selections}`\n : `Choose up to ${addOn.max_selections}`}\n </span>\n )}\n </div>\n {(addOn.is_required || !minMet) && (\n <span\n data-cimplify-addon-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-addon-options\n className={cn("divide-y divide-border", classNames?.options)}\n >\n {addOn.options.map((option) => {\n const isSelected = isOptionSelected(option.id);\n\n return (\n <Checkbox.Root\n key={option.id}\n checked={isSelected}\n onCheckedChange={(checked) =>\n handleCheckedChange(addOn, option.id, checked)\n }\n value={option.id}\n data-cimplify-addon-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-addon-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-addon-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <span\n data-cimplify-addon-option-name\n className={cn(\n "flex-1 min-w-0 text-sm",\n classNames?.optionName,\n )}\n >\n {option.name}\n </span>\n\n {option.default_price != null && (\n <span className="text-sm text-muted-foreground">\n +<Price amount={option.default_price} currency={currency} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n' }] }, "billing-plan-selector": { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "billing-plan-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductBillingPlan, BillingFrequency } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useBillingPlans } from "./hooks/use-billing-plans";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BillingPlanSelectorClassNames {\n root?: string;\n plan?: string;\n activePlan?: string;\n frequency?: string;\n price?: string;\n setupFee?: string;\n trialBadge?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface BillingPlanSelectorProps {\n /** Product ID to fetch billing plans for. */\n productId: string;\n /** Override plans (skips fetch). For SSR, pass pre-fetched plans. */\n plans?: ProductBillingPlan[];\n /** Called when a plan is selected. */\n onPlanSelect?: (plan: ProductBillingPlan | null) => void;\n /** Currently selected plan ID. */\n selectedPlanId?: string | null;\n /** Currency the plan amounts are denominated in. */\n currency?: CurrencyCode;\n /** Show a "One-time purchase" option at the top. */\n showOneTimePurchase?: boolean;\n /** Label for the one-time purchase option. Default: "One-time purchase". */\n oneTimePurchaseLabel?: string;\n /** Custom plan card renderer. */\n renderPlan?: (plan: ProductBillingPlan, isActive: boolean) => React.ReactNode;\n className?: string;\n classNames?: BillingPlanSelectorClassNames;\n}\n\nconst FREQUENCY_LABELS: Record<BillingFrequency, string> = {\n weekly: "Weekly",\n biweekly: "Biweekly",\n monthly: "Monthly",\n quarterly: "Quarterly",\n annually: "Annually",\n};\n\nfunction formatMarkup(plan: ProductBillingPlan): string | null {\n if (!plan.markup_type || plan.markup_amount == null) return null;\n if (plan.markup_type === "percentage") return `+${plan.markup_amount}%`;\n return null;\n}\n\n/**\n * BillingPlanSelector \u2014 subscription/installment plan comparison and selection.\n *\n * Renders plan cards with frequency, markup, trial, and setup fee details.\n * Returns `null` when no plans are available (and one-time purchase is hidden).\n */\nexport function BillingPlanSelector({\n productId,\n plans: plansProp,\n currency,\n onPlanSelect,\n selectedPlanId,\n showOneTimePurchase = false,\n oneTimePurchaseLabel = "One-time purchase",\n renderPlan,\n className,\n classNames,\n}: BillingPlanSelectorProps): React.ReactElement | null {\n const { plans: fetched, isLoading } = useBillingPlans(productId, {\n enabled: plansProp === undefined,\n });\n\n const plans = plansProp ?? fetched;\n\n if (isLoading && plans.length === 0) {\n return (\n <div\n data-cimplify-billing-plan-selector\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (plans.length === 0 && !showOneTimePurchase) {\n return null;\n }\n\n const isOneTimePurchaseSelected = selectedPlanId === null;\n\n return (\n <div data-cimplify-billing-plan-selector className={cn(className, classNames?.root)}>\n {showOneTimePurchase && (\n <button\n type="button"\n onClick={() => onPlanSelect?.(null)}\n data-cimplify-billing-plan\n data-plan-type="one-time"\n data-active={isOneTimePurchaseSelected || undefined}\n aria-pressed={isOneTimePurchaseSelected}\n className={cn(\n classNames?.plan,\n isOneTimePurchaseSelected && classNames?.activePlan,\n )}\n >\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {oneTimePurchaseLabel}\n </span>\n </button>\n )}\n\n {plans.map((plan) => {\n const isActive = selectedPlanId === plan.id;\n const markup = formatMarkup(plan);\n\n return (\n <button\n key={plan.id}\n type="button"\n onClick={() => onPlanSelect?.(plan)}\n data-cimplify-billing-plan\n data-plan-type={plan.plan_type}\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.plan, isActive && classNames?.activePlan)}\n >\n {renderPlan ? (\n renderPlan(plan, isActive)\n ) : (\n <>\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {FREQUENCY_LABELS[plan.frequency]}\n </span>\n\n {markup && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n {markup}\n </span>\n )}\n\n {plan.markup_type === "fixed" && plan.markup_amount != null && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n +<Price amount={plan.markup_amount} currency={currency} />\n </span>\n )}\n\n {plan.trial_days > 0 && (\n <span data-cimplify-billing-trial className={classNames?.trialBadge}>\n {plan.trial_days}-day trial\n </span>\n )}\n\n {plan.setup_fee > 0 && (\n <span data-cimplify-billing-setup-fee className={classNames?.setupFee}>\n Setup fee: <Price amount={plan.setup_fee} currency={currency} />\n </span>\n )}\n\n {plan.plan_type === "installment" && plan.installment_periods != null && (\n <span data-cimplify-billing-periods>\n {plan.installment_periods} payments\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "accommodation-card": { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn", "strip-html"], "files": [{ "path": "cards/accommodation-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function AccommodationCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per night\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />/night\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Amenity / tag pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Guest capacity */}\n {product.general_service_capacity != null && (\n <div className="flex items-center gap-1.5 mt-2 text-[11px] text-muted-foreground">\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />\n </svg>\n {product.general_service_capacity} guest{product.general_service_capacity > 1 ? "s" : ""}\n </div>\n )}\n\n {/* Extended stay pricing */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {cancellationHours != null && (\n <span className="text-emerald-600 font-medium">\n Free cancellation {cancellationHours}h\n </span>\n )}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span>Min. {product.min_order_quantity} nights</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book room →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "booking-list": { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"], "files": [{ "path": "booking-list.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { useBookings } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingListClassNames {\n root?: string;\n item?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface BookingListProps {\n /** Pre-fetched bookings (skips fetch). */\n bookings?: CustomerBooking[];\n /** Filter: "all", "upcoming", or "past". */\n filter?: "all" | "upcoming" | "past";\n /** Called when cancel is clicked on a booking. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked on a booking. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Called when a booking is clicked. */\n onBookingClick?: (booking: CustomerBooking) => void;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: BookingListClassNames;\n}\n\nexport function BookingList({\n bookings: bookingsProp,\n filter,\n onCancel,\n onReschedule,\n onBookingClick,\n renderBooking,\n emptyMessage = "No bookings yet",\n className,\n classNames,\n}: BookingListProps): React.ReactElement {\n const { bookings: fetched, isLoading } = useBookings({\n filter,\n enabled: bookingsProp === undefined,\n });\n\n const bookings = bookingsProp ?? fetched;\n\n if (isLoading && bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-booking-list className={cn(className, classNames?.root)}>\n {bookings.map((booking) => (\n <div\n key={booking.order_id}\n data-cimplify-booking-list-item\n className={classNames?.item}\n onClick={() => onBookingClick?.(booking)}\n role={onBookingClick ? "button" : undefined}\n tabIndex={onBookingClick ? 0 : undefined}\n onKeyDown={\n onBookingClick\n ? (e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault();\n onBookingClick(booking);\n }\n }\n : undefined\n }\n >\n <BookingCard\n booking={booking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n renderBooking={renderBooking}\n />\n </div>\n ))}\n </div>\n );\n}\n' }] }, "composite-selector": { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "composite-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport { NumberField } from "@base-ui/react/number-field";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n currency?: CurrencyCode;\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n currency,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplifyClient();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n const sortedGroups = useMemo(\n () =>\n [...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => ({\n ...group,\n _sortedComponents: group.components\n .filter((component) => component.is_available && !component.is_archived)\n .sort((a, b) => a.display_order - b.display_order),\n })),\n [groups],\n );\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn("space-y-6", className, classNames?.root)}>\n {sortedGroups.map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn("flex items-center justify-between py-3", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn("text-base font-bold", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : "Choose as many as you like"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded shrink-0",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? "radiogroup" : "group"}\n aria-label={group.name}\n className={cn("divide-y divide-border", classNames?.components)}\n >\n {group._sortedComponents.map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn("flex-1 min-w-0", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-composite-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-composite-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n {component.is_popular && (\n <span\n data-cimplify-composite-badge="popular"\n className={cn("text-[10px] uppercase tracking-wider text-primary font-medium", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge="premium"\n className={cn("text-[10px] uppercase tracking-wider text-amber-600 font-medium", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn("block text-xs text-muted-foreground/60", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn("flex items-center gap-2", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn("w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className="text-sm text-muted-foreground shrink-0">\n +<Price amount={component.price} currency={currency} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn("border-t border-border pt-4 space-y-1 text-sm", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} currency={currency} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} currency={currency} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn("flex justify-between font-medium pt-1 border-t border-border", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} currency={currency} className="text-primary" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn("flex items-center gap-2 text-sm text-muted-foreground", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn("text-sm text-destructive", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n' }] }, "product-image-gallery": { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-image-gallery.tsx", "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 \u2014 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' }] }, "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] } };
|
|
6452
|
+
` }] }, "cart-drawer": { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"], "files": [{ "path": "cart-drawer.tsx", "content": '"use client";\n\nimport React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";\nimport { CartSummary } from "./cart-summary";\nimport { Price } from "@cimplify/sdk/react";\nimport { useCart } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface CartDrawerCtx {\n isOpen: boolean;\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\nconst CartDrawerContext = createContext<CartDrawerCtx | null>(null);\n\nexport function useCartDrawer(): CartDrawerCtx {\n const ctx = useContext(CartDrawerContext);\n if (!ctx) {\n throw new Error("useCartDrawer must be used within <CartDrawerProvider>");\n }\n return ctx;\n}\n\ninterface ProviderProps {\n children: React.ReactNode;\n /** Auto-open the drawer whenever the cart\'s pendingOpCount goes from 0 \u2192 >0. Default: true. */\n openOnAdd?: boolean;\n}\n\nexport function CartDrawerProvider({ children, openOnAdd = true }: ProviderProps): React.ReactElement {\n const [isOpen, setIsOpen] = useState(false);\n const cart = useCart();\n const lastPendingRef = useRef(0);\n\n const open = useCallback(() => setIsOpen(true), []);\n const close = useCallback(() => setIsOpen(false), []);\n const toggle = useCallback(() => setIsOpen((v) => !v), []);\n\n useEffect(() => {\n if (!openOnAdd) return;\n if (cart.pendingOpCount > lastPendingRef.current && cart.pendingOpCount > 0) {\n setIsOpen(true);\n }\n lastPendingRef.current = cart.pendingOpCount;\n }, [cart.pendingOpCount, openOnAdd]);\n\n const value = useMemo<CartDrawerCtx>(() => ({ isOpen, open, close, toggle }), [isOpen, open, close, toggle]);\n return <CartDrawerContext.Provider value={value}>{children}</CartDrawerContext.Provider>;\n}\n\nexport interface CartDrawerProps {\n /** Called when "Checkout" is clicked. Drawer auto-closes first. */\n onCheckout?: () => void;\n /** Called when "Continue Shopping" is clicked. Defaults to closing. */\n onContinueShopping?: () => void;\n /** Called when the empty state\'s CTA is clicked. */\n onShop?: () => void;\n /** Heading. */\n title?: string;\n /** Free-shipping threshold (in business currency, numeric). 0 disables the progress bar. */\n freeShippingThreshold?: number;\n /** Custom class on the panel. */\n className?: string;\n}\n\n/**\n * Animate a number toward `target` over ~250ms. Used for subtotal so it\n * feels alive when items are added/removed.\n */\nfunction useAnimatedNumber(target: number, durationMs = 250) {\n const [value, setValue] = useState(target);\n const fromRef = useRef(target);\n const startRef = useRef<number | null>(null);\n useEffect(() => {\n fromRef.current = value;\n startRef.current = null;\n let raf = 0;\n const tick = (t: number) => {\n if (startRef.current == null) startRef.current = t;\n const p = Math.min(1, (t - startRef.current) / durationMs);\n const eased = 1 - Math.pow(1 - p, 3);\n setValue(fromRef.current + (target - fromRef.current) * eased);\n if (p < 1) raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n // value intentionally omitted \u2014 only re-trigger on target change\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, durationMs]);\n return value;\n}\n\nexport function CartDrawer({\n onCheckout,\n onContinueShopping,\n onShop,\n title = "Cart",\n freeShippingThreshold = 0,\n className,\n}: CartDrawerProps): React.ReactElement {\n const { isOpen, close } = useCartDrawer();\n const cart = useCart();\n const subtotalNum = parsePrice(cart.subtotal);\n const animatedSubtotal = useAnimatedNumber(subtotalNum);\n\n // Lock body scroll + close on Escape while open.\n useEffect(() => {\n if (!isOpen) return;\n const original = document.body.style.overflow;\n document.body.style.overflow = "hidden";\n const onKey = (e: KeyboardEvent) => {\n if (e.key === "Escape") close();\n };\n window.addEventListener("keydown", onKey);\n return () => {\n document.body.style.overflow = original;\n window.removeEventListener("keydown", onKey);\n };\n }, [isOpen, close]);\n\n const handleCheckout = () => {\n close();\n onCheckout?.();\n };\n\n const handleContinue = () => {\n onContinueShopping?.();\n close();\n };\n\n const handleShop = () => {\n onShop?.();\n close();\n };\n\n // Free-shipping progress\n const showShippingBar = freeShippingThreshold > 0 && !cart.isEmpty;\n const remainingForShipping = Math.max(0, freeShippingThreshold - subtotalNum);\n const shippingProgress = freeShippingThreshold > 0 ? Math.min(100, (subtotalNum / freeShippingThreshold) * 100) : 0;\n const shippingUnlocked = subtotalNum >= freeShippingThreshold && freeShippingThreshold > 0;\n\n return (\n <div\n data-cimplify-cart-drawer\n data-open={isOpen ? "true" : "false"}\n aria-hidden={!isOpen}\n className={cn(\n "fixed inset-0 z-[200]",\n isOpen ? "pointer-events-auto" : "pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n onClick={close}\n className={cn(\n "absolute inset-0 bg-foreground/40 backdrop-blur-sm transition-opacity duration-300",\n isOpen ? "opacity-100" : "opacity-0",\n )}\n />\n\n {/* Panel */}\n <aside\n role="dialog"\n aria-modal="true"\n aria-label={title}\n className={cn(\n "absolute top-0 right-0 h-full w-full sm:max-w-[480px] bg-background shadow-2xl flex flex-col",\n "transition-transform duration-300",\n isOpen ? "translate-x-0" : "translate-x-full",\n // ease-out cubic-bezier for a "pull" feel\n "[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]",\n className,\n )}\n >\n {/* Header */}\n <header className="relative flex items-center justify-between gap-4 px-6 py-5 shrink-0">\n <div className="flex items-baseline gap-2">\n <h2 className="text-xl font-bold tracking-tight m-0">{title}</h2>\n {cart.itemCount > 0 && (\n <span className="text-sm text-muted-foreground tabular-nums">\n {cart.itemCount} {cart.itemCount === 1 ? "item" : "items"}\n </span>\n )}\n </div>\n <button\n type="button"\n onClick={close}\n aria-label="Close cart"\n className="grid place-items-center w-9 h-9 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"\n >\n <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </header>\n\n {/* Free-shipping bar */}\n {showShippingBar && (\n <div data-cimplify-cart-shipping-bar className="px-6 pb-4 shrink-0">\n <div className="flex items-baseline justify-between gap-2 mb-1.5 text-xs">\n {shippingUnlocked ? (\n <span className="font-medium text-primary">\u2713 Free shipping unlocked</span>\n ) : (\n <span className="text-muted-foreground">\n <Price amount={remainingForShipping} className="font-semibold text-foreground" /> away from free shipping\n </span>\n )}\n </div>\n <div className="h-1 rounded-full bg-muted overflow-hidden">\n <div\n className="h-full bg-primary transition-[width] duration-500 ease-out"\n style={{ width: `${shippingProgress}%` }}\n />\n </div>\n </div>\n )}\n\n {/* Items */}\n <div className="flex-1 overflow-y-auto px-6">\n {cart.isEmpty ? (\n <EmptyState onShop={handleShop} />\n ) : (\n <CartSummary showTotals={false} showCheckoutButton={false} />\n )}\n </div>\n\n {/* Footer */}\n {!cart.isEmpty && (\n <footer className="border-t border-border px-6 py-5 shrink-0 space-y-3 bg-background">\n <div className="flex items-baseline justify-between">\n <span className="text-sm text-muted-foreground">Subtotal</span>\n <Price amount={animatedSubtotal} className="text-lg font-bold tabular-nums" />\n </div>\n <p className="text-[11px] text-muted-foreground">\n Tax and shipping calculated at checkout.\n </p>\n <button\n type="button"\n onClick={handleCheckout}\n className="w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Checkout \u2014 <Price amount={cart.subtotal} className="tabular-nums" />\n </button>\n <button\n type="button"\n onClick={handleContinue}\n className="w-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"\n >\n Continue shopping\n </button>\n <div className="flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase tracking-wider pt-1">\n <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />\n </svg>\n <span>Secure checkout</span>\n </div>\n </footer>\n )}\n </aside>\n </div>\n );\n}\n\nfunction EmptyState({ onShop }: { onShop: () => void }) {\n return (\n <div data-cimplify-cart-empty className="h-full grid place-items-center text-center py-16">\n <div className="space-y-5 max-w-[260px]">\n <div className="relative w-20 h-20 mx-auto">\n <div className="absolute inset-0 rounded-full bg-muted" />\n <svg\n className="relative w-10 h-10 m-auto top-1/2 -translate-y-1/2 text-muted-foreground/60"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l-1 12H6L5 9z" />\n </svg>\n </div>\n <div>\n <p className="text-base font-semibold m-0">Your cart is empty</p>\n <p className="text-sm text-muted-foreground mt-1">\n Discover something you\'ll love.\n </p>\n </div>\n <button\n type="button"\n onClick={onShop}\n className="inline-flex items-center gap-1.5 h-11 px-6 rounded-full bg-foreground text-background text-sm font-semibold hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Shop now\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />\n </svg>\n </button>\n </div>\n </div>\n );\n}\n' }] }, "product-sheet": { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "rich-text", "cn"], "files": [{ "path": "product-sheet.tsx", "content": '"use client";\n\nimport React, { useState } from "react";\nimport type { Product, ProductWithDetails, VariantView } from "@cimplify/sdk";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default 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: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return "variants" in product;\n}\n\n/**\n * ProductSheet \u2014 full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: "flex",\n flexDirection: "column",\n gap: "1rem",\n }}\n >\n <div\n style={{\n aspectRatio: "4/3",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.5rem",\n }}\n />\n <div\n style={{\n height: "1.5rem",\n width: "60%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n <div\n style={{\n height: "1rem",\n width: "30%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: "flex", flexDirection: "column", gap: "1rem" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n currency={getProductCurrency(fullProduct)}\n className={classNames?.price}\n />\n </div>\n\n {/* Description \u2014 merchant-authored HTML from the catalogue. */}\n <RichText\n html={fullProduct.description}\n data-cimplify-product-sheet-description\n className={cn("text-sm", classNames?.description)}\n />\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "customer-input-fields": { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "customer-input-fields.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { INPUT_FIELD_TYPE, type AddressValue, type DateRangeValue, type LocationValue, type PhoneValue, type ProductInputField, type SignatureValue } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { DatePicker } from "./date-picker";\nimport { TimePicker } from "./time-picker";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CustomerInputFieldsClassNames {\n root?: string;\n field?: string;\n label?: string;\n required?: string;\n helpText?: string;\n input?: string;\n select?: string;\n textarea?: string;\n fileInput?: string;\n colorInput?: string;\n radioGroup?: string;\n radioOption?: string;\n checkboxLabel?: string;\n priceAdjustment?: string;\n addressInput?: string;\n phoneInput?: string;\n signatureCanvas?: string;\n multiSelectGroup?: string;\n multiSelectOption?: string;\n dateRangeInput?: string;\n locationInput?: string;\n}\n\nexport interface CustomerInputFieldsProps {\n fields: ProductInputField[];\n values: Record<string, unknown>;\n onChange: (values: Record<string, unknown>) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n currency?: CurrencyCode;\n className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\n currency,\n className,\n classNames,\n}: CustomerInputFieldsProps): React.ReactElement | null {\n const clientContext = useOptionalCimplifyClient();\n\n if (fields.length === 0) return null;\n\n const sorted = [...fields].sort((a, b) => a.display_order - b.display_order);\n\n const setValue = useCallback(\n (fieldId: string, value: unknown) => {\n onChange({ ...values, [fieldId]: value });\n },\n [values, onChange],\n );\n\n const defaultFileUpload = useCallback(\n async (file: File): Promise<string> => {\n if (onFileUpload) return onFileUpload(file, {} as ProductInputField);\n if (!clientContext?.client) throw new Error("No upload provider available");\n const result = await clientContext.client.uploads.upload(file);\n if (!result.ok) throw new Error(result.error.message);\n return result.value.url;\n },\n [onFileUpload, clientContext],\n );\n\n return (\n <div data-cimplify-customer-inputs className={cn("space-y-4", className, classNames?.root)}>\n {sorted.map((field) => (\n <InputField\n key={field.id}\n field={field}\n value={values[field.id]}\n onValueChange={(value) => setValue(field.id, value)}\n onFileUpload={onFileUpload ?? (clientContext?.client ? (_file, _field) => defaultFileUpload(_file) : undefined)}\n currency={currency}\n classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\n field,\n value,\n onValueChange,\n onFileUpload,\n currency,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n currency?: CurrencyCode;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const hasPriceAdjustment = field.price_adjustment != null && parsePrice(field.price_adjustment) > 0;\n\n return (\n <div data-cimplify-customer-input data-field-type={field.field_type} className={classNames?.field}>\n <div className="flex items-center gap-2 mb-1.5">\n <label className={cn("text-sm font-semibold", classNames?.label)}>\n {field.name}\n </label>\n {field.is_required && (\n <span className={cn("text-xs text-destructive", classNames?.required)}>Required</span>\n )}\n {hasPriceAdjustment && (\n <span className={cn("text-xs text-muted-foreground", classNames?.priceAdjustment)}>\n +<Price amount={field.price_adjustment!} currency={currency} />\n </span>\n )}\n </div>\n\n {field.help_text && (\n <p className={cn("text-xs text-muted-foreground mb-1.5", classNames?.helpText)}>\n {field.help_text}\n </p>\n )}\n\n <FieldInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n </div>\n );\n}\n\nfunction FieldInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const inputClass = cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring",\n classNames?.input,\n );\n\n switch (field.field_type) {\n case INPUT_FIELD_TYPE.Text:\n case INPUT_FIELD_TYPE.Url:\n return (\n <input\n type={field.field_type === INPUT_FIELD_TYPE.Url ? "url" : "text"}\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Textarea:\n return (\n <textarea\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n rows={3}\n className={cn(inputClass, "resize-none", classNames?.textarea)}\n />\n );\n\n case INPUT_FIELD_TYPE.Number:\n return (\n <input\n type="number"\n value={typeof value === "number" ? value : ""}\n onChange={(e) => onValueChange(e.target.value ? parseFloat(e.target.value) : undefined)}\n placeholder={field.placeholder}\n required={field.is_required}\n min={field.validation?.min_value ?? undefined}\n max={field.validation?.max_value ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Select:\n return (\n <select\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={cn(inputClass, classNames?.select)}\n >\n <option value="">{field.placeholder || "Select..."}</option>\n {(field.options || []).map((opt) => (\n <option key={opt} value={opt}>{opt}</option>\n ))}\n </select>\n );\n\n case INPUT_FIELD_TYPE.Radio:\n return (\n <div className={cn("space-y-2", classNames?.radioGroup)}>\n {(field.options || []).map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.radioOption,\n )}\n >\n <input\n type="radio"\n name={field.id}\n value={opt}\n checked={value === opt}\n onChange={() => onValueChange(opt)}\n className="accent-primary"\n />\n {opt}\n </label>\n ))}\n </div>\n );\n\n case INPUT_FIELD_TYPE.Checkbox:\n return (\n <label className={cn("flex items-center gap-2 text-sm cursor-pointer", classNames?.checkboxLabel)}>\n <input\n type="checkbox"\n checked={value === true}\n onChange={(e) => onValueChange(e.target.checked)}\n className="accent-primary w-4 h-4"\n />\n {field.placeholder || field.name}\n </label>\n );\n\n case INPUT_FIELD_TYPE.Color:\n return (\n <input\n type="color"\n value={typeof value === "string" ? value : "#000000"}\n onChange={(e) => onValueChange(e.target.value)}\n className={cn("w-12 h-10 rounded-md border border-input cursor-pointer", classNames?.colorInput)}\n />\n );\n\n case INPUT_FIELD_TYPE.Date:\n return (\n <DatePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.File:\n case INPUT_FIELD_TYPE.Image:\n return (\n <FileUploadInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Email:\n return (\n <input\n type="email"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder || "email@example.com"}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.DateTime: {\n const stringValue = typeof value === "string" ? value : "";\n const [datePart, timePartRaw] = stringValue.includes("T")\n ? stringValue.split("T", 2)\n : [stringValue, ""];\n const timePart = (timePartRaw ?? "").slice(0, 5);\n const commit = (nextDate: string, nextTime: string): void => {\n if (!nextDate && !nextTime) {\n onValueChange(undefined);\n return;\n }\n if (!nextDate) {\n onValueChange(`${nextTime}`);\n return;\n }\n const combined = `${nextDate}T${nextTime || "00:00"}`;\n const parsed = new Date(combined);\n onValueChange(Number.isNaN(parsed.getTime()) ? combined : parsed.toISOString());\n };\n return (\n <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">\n <DatePicker\n value={datePart ?? ""}\n onChange={(next) => commit(next, timePart)}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={`${field.name} date`}\n required={field.is_required}\n />\n <TimePicker\n value={timePart}\n onChange={(next) => commit(datePart ?? "", next)}\n placeholder="Time"\n aria-label={`${field.name} time`}\n />\n </div>\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <TimePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a time"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.MultiSelect:\n return (\n <MultiSelectInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.DateRange:\n return (\n <DateRangeInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Address:\n return (\n <AddressInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Location:\n return (\n <LocationInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Phone:\n return (\n <PhoneInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Signature:\n return (\n <SignatureInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n default:\n return (\n <input\n type="text"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n className={inputClass}\n />\n );\n }\n}\n\nfunction FileUploadInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const [isUploading, setIsUploading] = React.useState(false);\n const fileUrl = typeof value === "string" ? value : undefined;\n const acceptedFormats = field.validation?.accepted_formats;\n const accept = acceptedFormats\n ? acceptedFormats.map((f) => `.${f}`).join(",")\n : field.field_type === INPUT_FIELD_TYPE.Image\n ? "image/*"\n : undefined;\n\n const handleFile = async (file: File) => {\n if (!onFileUpload) return;\n\n if (field.validation?.max_size_mb) {\n const maxBytes = field.validation.max_size_mb * 1024 * 1024;\n if (file.size > maxBytes) {\n return;\n }\n }\n\n setIsUploading(true);\n try {\n const url = await onFileUpload(file, field);\n onValueChange(url);\n } finally {\n setIsUploading(false);\n }\n };\n\n return (\n <div className={cn("space-y-2", classNames?.fileInput)}>\n {fileUrl ? (\n <div className="flex items-center gap-3">\n {field.field_type === INPUT_FIELD_TYPE.Image && (\n <img src={fileUrl} alt="Uploaded" className="w-16 h-16 object-cover rounded-md border border-border" />\n )}\n <div className="flex-1 min-w-0">\n <p className="text-sm text-foreground truncate">{fileUrl.split("/").pop()}</p>\n </div>\n <button\n type="button"\n onClick={() => onValueChange(undefined)}\n className="text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Remove\n </button>\n </div>\n ) : (\n <label className="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-primary/40 transition-colors">\n <input\n type="file"\n accept={accept}\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file) void handleFile(file);\n }}\n className="hidden"\n disabled={isUploading}\n />\n {isUploading ? (\n <span className="text-sm text-muted-foreground">Uploading...</span>\n ) : (\n <>\n <span className="text-sm text-muted-foreground">\n {field.placeholder || `Upload ${field.field_type === INPUT_FIELD_TYPE.Image ? "image" : "file"}`}\n </span>\n {acceptedFormats && (\n <span className="text-xs text-muted-foreground/60 mt-1">\n {acceptedFormats.map((f) => f.toUpperCase()).join(", ")}\n {field.validation?.max_size_mb && ` \xB7 Max ${field.validation.max_size_mb}MB`}\n </span>\n )}\n </>\n )}\n </label>\n )}\n </div>\n );\n}\n\nfunction MultiSelectInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const selected = Array.isArray(value) ? (value as string[]) : [];\n const options = field.options || [];\n const maxSelections = field.validation?.max_selections;\n\n const toggle = (opt: string) => {\n const next = selected.includes(opt)\n ? selected.filter((s) => s !== opt)\n : maxSelections && selected.length >= maxSelections\n ? selected\n : [...selected, opt];\n onValueChange(next.length > 0 ? next : undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.multiSelectGroup)}>\n {options.map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.multiSelectOption,\n )}\n >\n <input\n type="checkbox"\n checked={selected.includes(opt)}\n onChange={() => toggle(opt)}\n className="accent-primary w-4 h-4"\n />\n {opt}\n </label>\n ))}\n {maxSelections && (\n <p className="text-xs text-muted-foreground">\n {selected.length}/{maxSelections} selected\n </p>\n )}\n </div>\n );\n}\n\nfunction DateRangeInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const range = (value && typeof value === "object" ? value : {}) as DateRangeValue;\n\n const update = (key: "start" | "end", v: string) => {\n const next = { ...range, [key]: v };\n onValueChange(next.start || next.end ? next : undefined);\n };\n\n return (\n <div className={cn("grid grid-cols-1 sm:grid-cols-2 gap-3", classNames?.dateRangeInput)}>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">Start</label>\n <DatePicker\n value={range.start || ""}\n onChange={(next) => update("start", next)}\n required={field.is_required}\n placeholder="Start date"\n aria-label={`${field.name} start date`}\n maxDate={range.end ? new Date(`${range.end}T00:00`) : undefined}\n />\n </div>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">End</label>\n <DatePicker\n value={range.end || ""}\n onChange={(next) => update("end", next)}\n required={field.is_required}\n placeholder="End date"\n aria-label={`${field.name} end date`}\n minDate={range.start ? new Date(`${range.start}T00:00`) : undefined}\n />\n </div>\n </div>\n );\n}\n\nfunction AddressInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const addr = (value && typeof value === "object" ? value : null) as AddressValue | null;\n const [inputText, setInputText] = React.useState(addr?.formatted_address ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const fallback = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(fallback);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n const addressValue: AddressValue = {\n formatted_address: d.formatted_address || fallback,\n street_address: d.street_address,\n apartment: d.apartment,\n city: d.city,\n region: d.region,\n postal_code: d.postal_code,\n country: d.country,\n latitude: d.latitude,\n longitude: d.longitude,\n place_id: d.place_id,\n };\n setInputText(addressValue.formatted_address);\n onValueChange(addressValue);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("space-y-2 relative", classNames?.addressInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for an address..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {addr && (\n <input\n type="text"\n value={addr.apartment || ""}\n onChange={(e) => onValueChange({ ...addr, apartment: e.target.value || undefined })}\n placeholder="Apt, suite, unit (optional)"\n className={inputClass}\n />\n )}\n </div>\n );\n}\n\nfunction LocationInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const loc = (value && typeof value === "object" ? value : null) as LocationValue | null;\n const [inputText, setInputText] = React.useState(loc?.label ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const label = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(label);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n onValueChange({\n latitude: d.latitude,\n longitude: d.longitude,\n label: d.formatted_address || label,\n } satisfies LocationValue);\n setInputText(d.formatted_address || label);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("relative", classNames?.locationInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for a location..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {loc && (\n <p className="text-xs text-muted-foreground mt-1">\n {loc.latitude.toFixed(4)}, {loc.longitude.toFixed(4)}\n </p>\n )}\n </div>\n );\n}\n\nfunction PhoneInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const phone = (value && typeof value === "object" ? value : null) as PhoneValue | null;\n const [code, setCode] = React.useState(phone?.country_code ?? "+1");\n const [number, setNumber] = React.useState(phone?.number ?? "");\n\n const update = (nextCode: string, nextNumber: string) => {\n setCode(nextCode);\n setNumber(nextNumber);\n if (nextNumber) {\n onValueChange({\n country_code: nextCode,\n number: nextNumber,\n formatted: `${nextCode} ${nextNumber}`,\n } satisfies PhoneValue);\n } else {\n onValueChange(undefined);\n }\n };\n\n return (\n <div className={cn("flex gap-2", classNames?.phoneInput)}>\n <input\n type="text"\n value={code}\n onChange={(e) => update(e.target.value, number)}\n placeholder="+1"\n className={cn(inputClass, "w-20 shrink-0")}\n />\n <input\n type="tel"\n value={number}\n onChange={(e) => update(code, e.target.value)}\n placeholder={field.placeholder || "Phone number"}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n );\n}\n\nfunction SignatureInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const sig = (value && typeof value === "object" ? value : null) as SignatureValue | null;\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const isDrawing = React.useRef(false);\n\n const getCtx = () => canvasRef.current?.getContext("2d") ?? null;\n\n const startDraw = (e: React.PointerEvent) => {\n const ctx = getCtx();\n if (!ctx) return;\n isDrawing.current = true;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.beginPath();\n ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);\n canvasRef.current!.setPointerCapture(e.pointerId);\n };\n\n const draw = (e: React.PointerEvent) => {\n if (!isDrawing.current) return;\n const ctx = getCtx();\n if (!ctx) return;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.lineWidth = 2;\n ctx.lineCap = "round";\n const style = getComputedStyle(canvasRef.current!);\n ctx.strokeStyle = style.color || "#000";\n ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);\n ctx.stroke();\n };\n\n const endDraw = () => {\n if (!isDrawing.current) return;\n isDrawing.current = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n onValueChange({\n data_url: canvas.toDataURL("image/png"),\n signer_name: sig?.signer_name,\n } satisfies SignatureValue);\n };\n\n const clear = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext("2d");\n if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);\n onValueChange(undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.signatureCanvas)}>\n <div className="relative rounded-md border border-input overflow-hidden">\n <canvas\n ref={canvasRef}\n width={400}\n height={150}\n className="w-full touch-none cursor-crosshair bg-background text-foreground"\n onPointerDown={startDraw}\n onPointerMove={draw}\n onPointerUp={endDraw}\n onPointerLeave={endDraw}\n />\n {sig?.data_url && (\n <button\n type="button"\n onClick={clear}\n className="absolute top-1.5 right-1.5 text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Clear\n </button>\n )}\n </div>\n {!sig?.data_url && (\n <p className="text-xs text-muted-foreground">\n {field.placeholder || "Draw your signature above"}\n </p>\n )}\n </div>\n );\n}\n\nfunction createSessionToken(): string {\n if (typeof crypto !== "undefined" && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `places_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n' }] }, "bundle-selector": { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "bundle-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType, DurationUnit } from "@cimplify/sdk";\nimport type { Money, CurrencyCode } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n currency?: CurrencyCode;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n currency,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef("");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === "fixed" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === "percentage_discount" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === "fixed_discount" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn("space-y-4", className, classNames?.root)}>\n <div\n data-cimplify-bundle-heading\n className={cn("flex items-center justify-between py-3", classNames?.heading)}\n >\n <span className="text-base font-bold">Included in this bundle</span>\n </div>\n\n <div data-cimplify-bundle-components className={cn("divide-y divide-border", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n currency={currency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn("border-t border-border pt-4 flex justify-between text-sm", classNames?.summary)}\n >\n <span className="text-muted-foreground">Bundle price</span>\n <Price amount={bundlePrice} currency={currency} className="font-medium text-primary" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn("flex justify-between text-sm", classNames?.savings)}\n >\n <span className="text-muted-foreground">You save</span>\n <Price amount={discountValue} currency={currency} className="text-green-600 font-medium" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? "0")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\nfunction formatDuration(minutes: number, unit?: DurationUnit): string {\n if (unit === "hours" || (!unit && minutes >= 60 && minutes % 60 === 0)) {\n const h = Math.round(minutes / 60);\n return `${h}h`;\n }\n if (unit === "days" || unit === "nights") {\n const d = Math.round(minutes / 1440);\n return `${d}${unit === "nights" ? "n" : "d"}`;\n }\n return `${minutes}min`;\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n currency?: CurrencyCode;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n currency,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn("py-4", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn("flex items-center justify-between gap-3", classNames?.componentHeader)}\n >\n <div className="flex items-center gap-2">\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-bundle-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service{component.duration_minutes != null && (\n <> · {formatDuration(component.duration_minutes, component.duration_unit)}</>\n )}\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-bundle-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n </div>\n <span className="text-sm text-muted-foreground">\n <Price amount={displayPrice} currency={currency} />\n </span>\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn("mt-3 divide-y divide-border", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n <span\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span className="flex-1 text-sm">\n {variant.display_name}\n </span>\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn("text-sm text-muted-foreground", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? "+" : ""}\n <Price amount={variant.price_adjustment} currency={currency} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "product-model-3d": { "name": "product-model-3d", "title": "ProductModel3D", "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-model-3d.tsx", "content": '"use client";\n\nimport React, { useEffect, useState } from "react";\n\nexport interface ProductModel3DProps {\n /** glTF or GLB URL. */\n src: string;\n /** Optional USDZ URL for iOS AR Quick Look (Safari/WebKit). */\n iosSrc?: string;\n /** Poster image shown while the model loads (and as a fallback). */\n poster?: string;\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "3/4";\n /** Enable AR mode. Lights up on iOS (Quick Look) + Android (Scene Viewer). Default `true`. */\n ar?: boolean;\n autoRotate?: boolean;\n cameraControls?: boolean;\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/9": { aspectRatio: "16/9" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nconst MODEL_VIEWER_CDN =\n "https://unpkg.com/@google/model-viewer@4.0.0/dist/model-viewer.min.js";\n\n// Module-level cache so multiple <ProductModel3D> instances share one script load.\nlet modelViewerLoadPromise: Promise<void> | null = null;\nfunction ensureModelViewer(): Promise<void> {\n if (typeof window === "undefined") return Promise.resolve();\n if (modelViewerLoadPromise) return modelViewerLoadPromise;\n if (window.customElements?.get("model-viewer")) {\n modelViewerLoadPromise = Promise.resolve();\n return modelViewerLoadPromise;\n }\n modelViewerLoadPromise = new Promise<void>((resolve, reject) => {\n const script = document.createElement("script");\n script.type = "module";\n script.src = MODEL_VIEWER_CDN;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error("Failed to load model-viewer"));\n document.head.appendChild(script);\n });\n return modelViewerLoadPromise;\n}\n\n/**\n * ProductModel3D \u2014 interactive 3D product viewer wrapping Google\'s\n * `<model-viewer>` web component. Script loads on demand (one tag per page,\n * cached). AR mode auto-resolves the right OS surface: USDZ Quick Look on iOS,\n * Scene Viewer on Android, WebXR where available. Poster image shows during\n * load and as a fallback if the script can\'t load.\n */\nexport function ProductModel3D({\n src,\n iosSrc,\n poster,\n alt,\n aspectRatio = "square",\n ar = true,\n autoRotate = true,\n cameraControls = true,\n className,\n}: ProductModel3DProps): React.ReactElement {\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n let cancelled = false;\n ensureModelViewer().then(\n () => {\n if (!cancelled) setReady(true);\n },\n () => {\n // Stay on the poster fallback if the CDN script fails.\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <div\n data-cimplify-product-model-3d\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {ready\n ? React.createElement("model-viewer", {\n src,\n "ios-src": iosSrc,\n alt,\n poster,\n ar: ar || undefined,\n "ar-modes": ar ? "webxr scene-viewer quick-look" : undefined,\n "camera-controls": cameraControls || undefined,\n "auto-rotate": autoRotate || undefined,\n "shadow-intensity": "1",\n style: { width: "100%", height: "100%", backgroundColor: "transparent" },\n })\n : poster\n ? React.createElement("img", {\n src: poster,\n alt: alt ?? "",\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", objectFit: "cover" },\n })\n : React.createElement("div", {\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", backgroundColor: "var(--muted, #f3f4f6)" },\n })}\n </div>\n );\n}\n' }] }, "product-card": { "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 \u2014 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 \u2014 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 \u2192 explicit variant \u2192 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 \u2014 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 \u2014 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' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useEffect,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatWidgetStarter } from "../types/support";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName = "Support",\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n\n const [open, setOpen] = useState(defaultOpen);\n const [unread, setUnread] = useState(0);\n\n const { messages, isLoading, isSending, error, isActive, send, startConversation } =\n useChat({ client, starters, pollInterval });\n\n const prevCountRef = useRef(messages.length);\n\n // Track unread when panel is closed\n useEffect(() => {\n if (!open && messages.length > prevCountRef.current) {\n const newMessages = messages.slice(prevCountRef.current);\n const incomingCount = newMessages.filter(\n (m) => m.sender_type !== "customer",\n ).length;\n if (incomingCount > 0) setUnread((n) => n + incomingCount);\n }\n prevCountRef.current = messages.length;\n }, [messages, open]);\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n setUnread(0);\n }, []);\n\n const handleClose = useCallback(() => setOpen(false), []);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n error={error}\n isActive={isActive}\n isLeft={isLeft}\n onClose={handleClose}\n onSend={send}\n onStartConversation={startConversation}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-red-500 text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n error,\n isActive,\n isLeft,\n onClose,\n onSend,\n onStartConversation,\n classNames,\n}: {\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n onClose: () => void;\n onSend: (content: string) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n className={classNames?.messages}\n />\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-red-200 bg-red-50 px-4 py-2 text-xs text-red-600">\n Something went wrong. Please try again.\n </div>\n )}\n\n {/* Input \u2014 shown once conversation is active or loading */}\n {(isActive || isLoading) && (\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={onSend}\n className={classNames?.input}\n />\n )}\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n onClose,\n className,\n}: {\n businessName: string;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-white/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n <div className="flex items-center gap-1.5 text-xs text-white/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction MessageList({\n messages,\n isLoading,\n isSending,\n className,\n}: {\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n className?: string;\n}) {\n const bottomRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n bottomRef.current?.scrollIntoView({ behavior: "smooth" });\n }, [messages.length, isSending]);\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col gap-1 overflow-y-auto bg-muted/50 px-5 py-5",\n className,\n )}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {messages.map((msg, i) => {\n const isCustomer = msg.sender_type === "customer";\n const prev = messages[i - 1];\n const sameSenderAsPrev = prev?.sender_type === msg.sender_type;\n const isOptimistic = msg.id.startsWith("opt_");\n\n return (\n <div key={msg.id}>\n {!sameSenderAsPrev && i > 0 && <div className="h-3" />}\n <div\n className={cn(\n "flex max-w-[85%]",\n isCustomer ? "ml-auto" : "mr-auto",\n )}\n >\n <div\n className={cn(\n "rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",\n isCustomer\n ? "bg-foreground text-background"\n : "border border-border bg-background text-foreground",\n isCustomer\n ? sameSenderAsPrev\n ? "rounded-tr"\n : "rounded-br"\n : sameSenderAsPrev\n ? "rounded-tl"\n : "rounded-bl",\n isOptimistic && "opacity-70",\n )}\n >\n {!isCustomer && !sameSenderAsPrev && (\n <span className="mb-1 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-600">\n <SparkleIcon className="h-[11px] w-[11px]" />\n AI\n </span>\n )}\n <div className="whitespace-pre-wrap">{msg.content}</div>\n </div>\n </div>\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n <div ref={bottomRef} />\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSend = useCallback(() => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n onSend(trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction SparkleIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2.5}\n >\n <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />\n </svg>\n );\n}\n' }] }, "order-history": { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-history.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { useOrders } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryClassNames {\n root?: string;\n item?: string;\n orderId?: string;\n status?: string;\n date?: string;\n total?: string;\n empty?: string;\n loading?: string;\n reorderButton?: string;\n}\n\nexport interface OrderHistoryProps {\n /** Override orders (skips fetch). For SSR, pass pre-fetched orders. */\n orders?: Order[];\n /** Filter by status. */\n status?: OrderStatus;\n /** Max orders to display. */\n limit?: number;\n /** Called when an order row is clicked. */\n onOrderClick?: (order: Order) => void;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n /** Called when the reorder button is clicked. If provided, a reorder button is rendered. */\n onReorder?: (order: Order) => void;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: OrderHistoryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\n/**\n * OrderHistory \u2014 list of past orders with status and totals.\n *\n * Fetches via `useOrders` unless pre-loaded orders are passed.\n */\nexport function OrderHistory({\n orders: ordersProp,\n status,\n limit,\n onOrderClick,\n renderOrder,\n onReorder,\n emptyMessage = "No orders yet",\n className,\n classNames,\n}: OrderHistoryProps): React.ReactElement {\n const { orders: fetched, isLoading } = useOrders({\n status,\n limit,\n enabled: ordersProp === undefined,\n });\n\n const orders = ordersProp ?? fetched;\n\n if (isLoading && orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history className={cn(className, classNames?.root)}>\n {orders.map((order) => (\n <button\n key={order.id}\n type="button"\n onClick={() => onOrderClick?.(order)}\n data-cimplify-order-history-item\n data-status={order.status}\n className={classNames?.item}\n >\n {renderOrder ? (\n renderOrder(order)\n ) : (\n <>\n <div data-cimplify-order-history-main>\n <span data-cimplify-order-history-id className={classNames?.orderId}>\n #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-history-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n </div>\n <div data-cimplify-order-history-details>\n <time\n data-cimplify-order-history-date\n dateTime={order.created_at}\n className={classNames?.date}\n >\n {new Date(order.created_at).toLocaleDateString()}\n </time>\n <span data-cimplify-order-history-items>\n {order.total_quantity} {order.total_quantity === 1 ? "item" : "items"}\n </span>\n <span data-cimplify-order-history-total className={classNames?.total}>\n <Price amount={order.total_price} />\n </span>\n </div>\n {onReorder && (\n <button\n type="button"\n onClick={(e) => {\n e.stopPropagation();\n onReorder(order);\n }}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "wholesale-product-card": { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"], "files": [{ "path": "cards/wholesale-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* MOQ badge */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-zinc-800 text-white px-2 py-0.5 rounded-md">\n MOQ: {product.min_order_quantity}\n </span>\n )}\n\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n {/* SKU */}\n {product.sku && (\n <p className="text-[10px] text-muted-foreground font-mono">\n {product.sku}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price range or single */}\n <div className="mt-2">\n {hasTiers ? (\n <PriceRange product={product} className="text-sm font-bold" />\n ) : (\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n )}\n </div>\n\n {/* Stock */}\n {status && (\n <div className="flex items-center gap-1.5 mt-2">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock\n ? status.low_stock ? "bg-amber-500" : "bg-emerald-500"\n : "bg-red-500",\n )} />\n <span className={cn(\n "text-[10.5px] font-medium",\n status.in_stock\n ? status.low_stock ? "text-amber-600" : "text-emerald-600"\n : "text-red-600",\n )}>\n {!status.in_stock\n ? "Out of stock"\n : status.stock_level != null\n ? `${status.stock_level.toLocaleString()} in stock`\n : "In stock"\n }\n </span>\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "standard-service-card": { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/standard-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ServiceCardLayoutProps extends CardLayoutProps {\n slots?: AvailableSlot[];\n onBook?: (product: Product) => void;\n}\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr${h > 1 ? "s" : ""}`;\n }\n return `${minutes} min`;\n}\n\nexport function StandardServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Duration pill */}\n {product.duration_minutes != null && (\n <span className="absolute bottom-3 left-3 inline-flex items-center gap-1 text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[12px] text-muted-foreground">\n <span className="w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse" />\n <span>Available</span>\n {hasDeposit && (\n <>\n <span className="text-border">\xB7</span>\n <span><Price amount={product.deposit_amount!} currency={currency} /> deposit</span>\n </>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "volume-pricing": { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "volume-pricing.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { QuantityPricingTier } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n currency?: CurrencyCode;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}\u2013${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n currency,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn("border border-border", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n "flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox="0 0 12 12"\n fill="none"\n aria-hidden="true"\n className={cn(\n "w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180",\n classNames?.triggerIcon,\n )}\n >\n <path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn("border-t border-border divide-y divide-border", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n "flex items-center justify-between px-4 py-2.5 text-sm",\n active\n ? cn("bg-primary/5 font-medium", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn("text-muted-foreground", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} currency={currency} prefix="" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license_key: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_grant: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n redemption_code: { label: "Code", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "digital-product-layout": { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/digital-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst DIGITAL_TYPE_LABELS: Record<string, string> = {\n download: "Digital download",\n license_key: "Software license",\n ticket: "Event ticket",\n access_grant: "Access pass",\n redemption_code: "Redemption code",\n};\n\nexport function DigitalProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const typeLabel = product.digital_type\n ? DIGITAL_TYPE_LABELS[product.digital_type] || product.digital_type\n : "Digital product";\n\n const isTicket = product.digital_type === "ticket";\n const isAccessPass = product.digital_type === "access_grant";\n const isDownload = product.digital_type === "download" || product.digital_type === "license_key";\n\n return (\n <div data-cimplify-product-layout="digital" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Type badge + tags */}\n <div className="flex flex-wrap items-center gap-2">\n <span data-cimplify-product-layout-badge className="text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-1 rounded-full">\n {typeLabel}\n </span>\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold mt-2 block" />\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Delivery notice */}\n {isDownload && (\n <div data-cimplify-product-layout-delivery className="flex items-center gap-3 px-4 py-3 bg-muted rounded-lg">\n <svg className="w-5 h-5 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">\n <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <div>\n <p className="text-sm font-medium">Instant delivery</p>\n <p className="text-xs text-muted-foreground">Download immediately after purchase</p>\n </div>\n </div>\n )}\n\n {/* Event info */}\n {isTicket && (product.event_date || product.venue) && (\n <div data-cimplify-product-layout-event className="border border-border p-4 space-y-2">\n {product.event_date && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Date:</span>\n <span className="font-medium">{new Date(product.event_date).toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>\n </div>\n )}\n {product.venue && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Venue:</span>\n <span className="font-medium">{product.venue}</span>\n </div>\n )}\n {product.ticket_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Type:</span>\n <span className="font-medium capitalize">{product.ticket_type}</span>\n </div>\n )}\n </div>\n )}\n\n {/* Access pass info */}\n {isAccessPass && (product.access_type || product.access_duration_days) && (\n <div data-cimplify-product-layout-access className="border border-border p-4 space-y-2">\n {product.access_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Access:</span>\n <span className="font-medium capitalize">{product.access_type}</span>\n </div>\n )}\n {product.access_level && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Level:</span>\n <span className="font-medium capitalize">{product.access_level}</span>\n </div>\n )}\n {product.access_duration_days && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Duration:</span>\n <span className="font-medium">{product.access_duration_days} days</span>\n </div>\n )}\n </div>\n )}\n\n {/* File details */}\n {isDownload && (product.file_size_mb || product.file_type || product.version || product.max_downloads) && (\n <div data-cimplify-product-layout-file-details className="grid grid-cols-2 gap-3">\n {product.file_type && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Format</p>\n <p className="text-sm font-semibold">{product.file_type.toUpperCase()}</p>\n </div>\n )}\n {product.file_size_mb != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Size</p>\n <p className="text-sm font-semibold">{product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)} GB` : `${product.file_size_mb} MB`}</p>\n </div>\n )}\n {product.max_downloads != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Downloads</p>\n <p className="text-sm font-semibold">{product.max_downloads}</p>\n </div>\n )}\n {product.version && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Version</p>\n <p className="text-sm font-semibold">v{product.version}</p>\n </div>\n )}\n {product.download_expires_days != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Expires</p>\n <p className="text-sm font-semibold">{product.download_expires_days} days</p>\n </div>\n )}\n {product.max_activations != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Activations</p>\n <p className="text-sm font-semibold">{product.max_activations}</p>\n </div>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (license variants, billing plans, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="More digital products"\n />\n )}\n </div>\n );\n}\n' }] }, "slot-picker": { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "slot-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React, { useMemo } from "react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useAvailableSlots } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n slotScarcity?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID \u2014 used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) \u2014 used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `"multi_day"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Show a "N left" urgency tag when a slot\'s remaining capacity is at or below\n * this number (and above zero). Slots at zero capacity render as sold out.\n * Default: 3. Set to 0 to disable scarcity tags.\n */\n lowCapacityThreshold?: number;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /**\n * Hide slots whose `start_time` is already in the past. Default: true.\n * Set to false to show elapsed slots greyed-out (still un-selectable).\n */\n hideElapsedSlots?: boolean;\n /**\n * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n * start is sooner than `now + minLeadMinutes` are filtered out (when\n * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n */\n minLeadMinutes?: number;\n /**\n * Service scheduling mode. When `"multi_day"`, each slot renders as a\n * stay summary (`"3 nights: Fri Apr 5, 3:00 PM \u2192 Mon Apr 8, 11:00 AM"`)\n * instead of the time-of-day label. Defaults to `"intraday"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit \u2014 used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value \u2014 used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): "morning" | "afternoon" | "evening" {\n const hour = parseInt(timeStr.split("T").pop()?.split(":")[0] ?? timeStr.split(":")[0], 10);\n if (hour < 12) return "morning";\n if (hour < 17) return "afternoon";\n return "evening";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: "Morning",\n afternoon: "Afternoon",\n evening: "Evening",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return (["morning", "afternoon", "evening"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(":");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? "PM" : "AM";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? "day" : "days";\n const v = value ?? 1;\n if (unit === "minutes") return v === 1 ? "minute" : "minutes";\n if (unit === "hours") return v === 1 ? "hour" : "hours";\n if (unit === "days") return v === 1 ? "day" : "days";\n if (unit === "weeks") return v === 1 ? "week" : "weeks";\n if (unit === "months") return v === 1 ? "month" : "months";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} \u2192 ${endLabel}`;\n }\n return `${startLabel} \u2192 ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n lowCapacityThreshold = 3,\n currency,\n schedulingMode = "intraday",\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n emptyMessage = "No available slots",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === "multi_day";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const rawSlots = slotsProp ?? fetched;\n // Drop slots that have already elapsed (or fall within the lead-time\n // window). Default behaviour because nothing the merchant can do at\n // the backend stops a client clock from being slightly ahead of the\n // last availability response \u2014 this is the same defence other\n // booking flows ship by default.\n const slots = useMemo(() => {\n if (!hideElapsedSlots) return rawSlots;\n const cutoff = Date.now() + minLeadMinutes * 60_000;\n return rawSlots.filter((slot) => {\n const start = Date.parse(slot.start_time);\n return Number.isNaN(start) || start >= cutoff;\n });\n }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: "", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : "";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn("flex flex-col gap-4", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || "all"}\n data-cimplify-slot-group\n className={cn("flex flex-col gap-2", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n "text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? "flex flex-col gap-2"\n : "grid grid-cols-3 sm:grid-cols-4 gap-2",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n const capacity = slot.capacity_available ?? slot.remaining_capacity;\n const soldOut = capacity === 0;\n const unavailable = slot.is_available === false || soldOut;\n const lowCapacity =\n lowCapacityThreshold > 0 &&\n capacity != null &&\n capacity > 0 &&\n capacity <= lowCapacityThreshold;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={unavailable}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n "inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through",\n isMultiDay && "justify-between text-left",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn("text-xs opacity-70", classNames?.slotPrice)}\n >\n <Price amount={slot.price} currency={currency} />\n </span>\n )}\n {lowCapacity && (\n <span\n data-cimplify-slot-scarcity\n className={cn("text-xs font-medium text-amber-600", classNames?.slotScarcity)}\n >\n {capacity} left\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "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 \u2014 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 \u2014 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' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "rental-service-card": { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/rental-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n minutes: "min",\n hours: "hr",\n days: "day",\n nights: "night",\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function RentalServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "day";\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Per-unit pill */}\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n {/* Price */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />/{unit}\n </span>\n\n {/* Capacity badge */}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-blue-50/90 text-blue-700 border border-blue-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n Up to {product.general_service_capacity} guests\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Info pills */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} currency={currency} /> deposit\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {/* Availability */}\n {status && (\n <div className="flex items-center gap-1.5 text-[12px]">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock ? "bg-emerald-500" : "bg-red-500",\n )} />\n <span className={status.in_stock ? "text-muted-foreground" : "text-red-600"}>\n {status.in_stock\n ? status.stock_level != null\n ? `${status.stock_level} available`\n : "Available"\n : "Unavailable"\n }\n </span>\n </div>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Reserve now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "store-video": { "name": "store-video", "title": "StoreVideo", "description": "Autoplay-muted-loop video for storefront heroes / product demos. Lazy-loaded, mobile-safe (playsInline), poster fallback for browsers without video.", "type": "component", "registryDependencies": [], "files": [{ "path": "store-video.tsx", "content": '"use client";\n\nimport React, { useEffect, useRef, useState } from "react";\n\nexport interface StoreVideoProps {\n src: string;\n /** Poster shown before play + as the fallback if `<video>` isn\'t supported. */\n poster?: string;\n /** Accessible label; also the alt of the fallback `<img>`. */\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "16/10" | "3/4";\n /** Show native browser controls. Default off for autoplay-friendly hero clips. */\n controls?: boolean;\n /** Default `true` \u2014 muted is the only way mobile browsers allow autoplay. */\n autoplay?: boolean;\n loop?: boolean;\n muted?: boolean;\n /** Lazy-load: defer the src until the player scrolls into view. Default `true`. */\n lazy?: boolean;\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/9": { aspectRatio: "16/9" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\n/**\n * StoreVideo \u2014 `<video>` with mobile-safe defaults (muted + playsInline + loop),\n * IntersectionObserver-driven lazy load, and a poster `<img>` fallback for\n * browsers without video support. Drop-in replacement for a static hero image.\n */\nexport function StoreVideo({\n src,\n poster,\n alt,\n aspectRatio = "16/9",\n controls = false,\n autoplay = true,\n loop = true,\n muted = true,\n lazy = true,\n className,\n}: StoreVideoProps): React.ReactElement {\n const ref = useRef<HTMLVideoElement>(null);\n const [inView, setInView] = useState(!lazy);\n\n useEffect(() => {\n if (!lazy || inView) return;\n const node = ref.current;\n if (!node || typeof IntersectionObserver === "undefined") {\n setInView(true);\n return;\n }\n const observer = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n setInView(true);\n observer.disconnect();\n }\n },\n { rootMargin: "200px" },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, [lazy, inView]);\n\n return (\n <div\n data-cimplify-store-video\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n <video\n ref={ref}\n src={inView ? src : undefined}\n poster={poster}\n autoPlay={autoplay}\n loop={loop}\n muted={muted}\n playsInline\n controls={controls}\n preload={lazy ? "metadata" : "auto"}\n aria-label={alt}\n style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}\n >\n {poster ? (\n <img\n src={poster}\n alt={alt ?? ""}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n ) : null}\n </video>\n </div>\n );\n}\n' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "subscription-card": { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/subscription-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst FREQUENCY_LABELS: Record<string, string> = {\n weekly: "/week",\n biweekly: "/2wk",\n monthly: "/mo",\n quarterly: "/qtr",\n annually: "/yr",\n};\n\nexport function SubscriptionCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const plans = product.billing_plans || [];\n const primaryPlan = plans[0];\n const frequency = primaryPlan?.frequency\n ? FREQUENCY_LABELS[primaryPlan.frequency] || `/${primaryPlan.frequency}`\n : "/mo";\n const hasTrial = primaryPlan?.trial_days != null && primaryPlan.trial_days > 0;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />{frequency}\n </span>\n\n {/* Trial badge */}\n {hasTrial && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50/90 text-emerald-700 border border-emerald-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n {primaryPlan!.trial_days}-day free trial\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Tags */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 3).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Billing plan options */}\n {plans.length > 1 && (\n <div className="mt-3 space-y-1">\n {plans.slice(0, 3).map((plan) => {\n const label = plan.frequency\n ? plan.frequency.charAt(0).toUpperCase() + plan.frequency.slice(1)\n : "Standard";\n const hasMarkup = plan.markup_type && plan.markup_amount;\n const isSavings = plan.markup_type === "percentage" && plan.markup_amount != null && plan.markup_amount < 0;\n\n return (\n <div key={plan.id} className="flex items-center justify-between text-[11px]">\n <span className="text-muted-foreground">{label}</span>\n <span className="font-medium">\n {hasMarkup && isSavings && (\n <span className="text-emerald-600 mr-1">Save {Math.abs(plan.markup_amount!)}%</span>\n )}\n </span>\n </div>\n );\n })}\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {hasTrial && (\n <span className="text-emerald-600 font-medium">Free trial</span>\n )}\n {primaryPlan?.setup_fee != null && primaryPlan.setup_fee > 0 && (\n <span><Price amount={primaryPlan.setup_fee} currency={currency} /> setup</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Subscribe →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "product-customizer": { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"], "files": [{ "path": "product-customizer.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from "react";\nimport { Button } from "@base-ui/react/button";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { ProductBillingPlan } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice, getUnitPriceAtQuantity, getProductCurrency } from "@cimplify/sdk";\nimport { formatDuration } from "./utils/format-duration";\nimport { useCart, useQuote } from "@cimplify/sdk/react";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { VariantSelector } from "@cimplify/sdk/react";\nimport { AddOnSelector } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { BookingSummary } from "./booking-summary";\nimport type { Staff } from "@cimplify/sdk";\nimport { CompositeSelector } from "@cimplify/sdk/react";\nimport { BundleSelector } from "@cimplify/sdk/react";\nimport { BillingPlanSelector } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { CustomerInputFields } from "./customer-input-fields";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (\n variantId: string | undefined,\n variant: VariantView | undefined,\n ) => void;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState("");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n\n const cart = useCart();\n\n const currency = getProductCurrency(product);\n const productType = product.type || "product";\n const isComposite = productType === "composite";\n const isBundle = productType === "bundle";\n const isDigital = productType === "digital";\n const isService = productType === "service";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setCustomerInputValues({});\n setSpecialInstructions("");\n setSelectedSlot(null);\n setSelectedStaffId(null);\n }, [product.id, product.min_order_quantity]);\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== "" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || "", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n }\n : {}),\n customerInputs: Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== "")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, customerInputValues, specialInstructions, isService, selectedSlot, selectedStaffId, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn("space-y-6", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n currency={currency}\n />\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className="block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className="flex flex-wrap gap-2"\n classNames={{\n option:\n "inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background",\n avatar: "w-6 h-6 rounded-full object-cover",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== "none" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn("text-sm text-muted-foreground", classNames?.depositInfo)}>\n {product.deposit_type === "fixed" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} currency={currency} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn("flex flex-wrap gap-1.5", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className="inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn("text-sm text-muted-foreground", classNames?.duration)}>\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder="Special instructions (e.g., no onions, extra sauce)"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn("pt-4 border-t border-border", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id="cimplify-customizer-validation"\n data-cimplify-customizer-validation\n className={cn("text-sm text-destructive mb-3", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className="flex items-center gap-4">\n <div className="flex flex-col gap-1">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className="text-xs text-muted-foreground text-center">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className="text-sm text-muted-foreground text-right shrink-0">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} currency={currency} className="font-medium text-foreground" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? "cimplify-customizer-validation" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n "flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? "Added to Cart" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n' }] }, "booking-page": { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"], "files": [{ "path": "booking-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport type { Service, Staff, AvailableSlot, CustomerBooking } from "@cimplify/sdk";\nimport type { Resource } from "./resource-picker";\nimport { useCart } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { ResourcePicker } from "./resource-picker";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "./utils/format-duration";\nimport { parsePrice } from "@cimplify/sdk";\n\nexport interface BookingPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n serviceInfo?: string;\n step?: string;\n stepTitle?: string;\n summary?: string;\n summaryRow?: string;\n confirmButton?: string;\n backButton?: string;\n error?: string;\n rescheduleInfo?: string;\n depositInfo?: string;\n cancellationPolicy?: string;\n resourceStep?: string;\n}\n\nexport interface BookingPageProps {\n /** The service being booked. */\n service: Service;\n /** The underlying product (for deposit/cancellation fields). Falls back to service fields. */\n product?: Product;\n /** Optional staff list for staff selection step. */\n staff?: Staff[];\n /** Optional resources for resource selection step (rooms, equipment, etc.). */\n resources?: Resource[];\n /** Number of participants. */\n participantCount?: number;\n /** Page title. */\n title?: string;\n /** Called after successfully adding to cart. */\n onBooked?: (slot: AvailableSlot, staffId: string | null) => void;\n /** Called when user wants to go back. */\n onBack?: () => void;\n /** Existing booking for reschedule mode. */\n existingBooking?: CustomerBooking;\n /** Called after a successful reschedule. */\n onRescheduled?: (booking: CustomerBooking, newSlot: AvailableSlot) => void;\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n className?: string;\n classNames?: BookingPageClassNames;\n}\n\nconst STEP = {\n SELECT_SLOT: "select-slot",\n SELECT_RESOURCE: "select-resource",\n SELECT_STAFF: "select-staff",\n CONFIRM: "confirm",\n} as const;\n\ntype BookingStep = (typeof STEP)[keyof typeof STEP];\n\nexport function BookingPage({\n service,\n product,\n staff,\n resources,\n participantCount,\n title,\n onBooked,\n onBack,\n existingBooking,\n onRescheduled,\n showPrice = true,\n className,\n classNames,\n}: BookingPageProps): React.ReactElement {\n const { addItem } = useCart();\n const { client } = useCimplifyClient();\n\n const currency = service.currency as CurrencyCode | undefined;\n\n const isReschedule = existingBooking !== undefined;\n\n const [step, setStep] = useState<BookingStep>(STEP.SELECT_SLOT);\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedDate, setSelectedDate] = useState<string | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const hasResourceStep = resources && resources.length > 0;\n const hasStaffStep = staff && staff.length > 0;\n\n // Deposit info \u2014 prefer product fields, then fall back to service metadata\n const depositType = product?.deposit_type;\n const depositAmount = product?.deposit_amount;\n const hasDeposit = depositType !== undefined && depositType !== "none" && depositAmount !== undefined && parsePrice(depositAmount) !== 0;\n\n // Cancellation policy\n const cancellationMinutes = product?.cancellation_window_minutes;\n const noShowFee = product?.no_show_fee;\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot, date: string) => {\n setSelectedSlot(slot);\n setSelectedDate(date);\n setError(null);\n if (hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasResourceStep, hasStaffStep],\n );\n\n const handleResourceSelect = useCallback(\n (resourceId: string | null) => {\n setSelectedResourceId(resourceId);\n if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasStaffStep],\n );\n\n const handleStaffSelect = useCallback((staffId: string | null) => {\n setSelectedStaffId(staffId);\n setStep(STEP.CONFIRM);\n }, []);\n\n const handleBack = useCallback(() => {\n setError(null);\n if (step === STEP.CONFIRM && hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else if (step === STEP.CONFIRM && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.SELECT_STAFF && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.CONFIRM || step === STEP.SELECT_STAFF || step === STEP.SELECT_RESOURCE) {\n setStep(STEP.SELECT_SLOT);\n } else {\n onBack?.();\n }\n }, [step, hasStaffStep, hasResourceStep, onBack]);\n\n const handleConfirm = useCallback(async () => {\n if (!selectedSlot || !selectedDate) return;\n\n setIsSubmitting(true);\n setError(null);\n\n try {\n if (isReschedule && existingBooking) {\n // Reschedule mode \u2014 call the scheduling API instead of adding to cart\n const serviceItem = existingBooking.service_items[0];\n const result = await client.scheduling.rescheduleBooking({\n order_id: existingBooking.order_id,\n line_item_id: serviceItem?.service_id ?? existingBooking.order_id,\n new_start_time: selectedSlot.start_time,\n new_end_time: selectedSlot.end_time,\n new_staff_id: selectedStaffId || undefined,\n reschedule_type: "customer",\n });\n\n if (!result.ok) {\n throw result.error;\n }\n\n onRescheduled?.(existingBooking, selectedSlot);\n } else {\n // Normal booking mode \u2014 add to cart\n const serviceProduct = {\n id: service.product_id || service.id,\n business_id: service.business_id || "",\n category_id: service.category_id || undefined,\n name: service.name,\n slug: service.id,\n description: service.description || undefined,\n image_url: service.image_url || undefined,\n default_price: (service.price || "0") as Product["default_price"],\n type: "service" as const,\n inventory_type: "none" as const,\n variant_strategy: "fetch_all" as const,\n is_active: service.is_available,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString(),\n };\n\n await addItem(serviceProduct, 1, {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId || undefined,\n resourceId: selectedResourceId || undefined,\n });\n\n onBooked?.(selectedSlot, selectedStaffId);\n }\n } catch (err) {\n const fallbackMessage = isReschedule ? "Failed to reschedule booking" : "Failed to add booking to cart";\n setError(err instanceof Error ? err.message : fallbackMessage);\n } finally {\n setIsSubmitting(false);\n }\n }, [selectedSlot, selectedDate, selectedStaffId, service, addItem, onBooked, isReschedule, existingBooking, client, onRescheduled]);\n\n return (\n <div data-cimplify-booking-page className={cn(className, classNames?.root)}>\n <div data-cimplify-booking-page-header className={classNames?.header}>\n {onBack && step === STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n {step !== STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n <h1 data-cimplify-booking-page-title className={classNames?.title}>\n {title || (isReschedule ? "Reschedule Booking" : `Book ${service.name}`)}\n </h1>\n </div>\n\n {isReschedule && existingBooking && (\n <div data-cimplify-booking-reschedule-info className={classNames?.rescheduleInfo}>\n <span>Rescheduling booking from {new Date(existingBooking.service_items[0]?.scheduled_start ?? existingBooking.created_at).toLocaleDateString()}</span>\n </div>\n )}\n\n <div data-cimplify-booking-service-info className={classNames?.serviceInfo}>\n <span data-cimplify-booking-service-name>{service.name}</span>\n <span data-cimplify-booking-service-duration>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n {service.price && (\n <span data-cimplify-booking-service-price>\n <Price amount={service.price} currency={currency} />\n </span>\n )}\n {hasDeposit && (\n <span data-cimplify-booking-deposit-info className={classNames?.depositInfo}>\n Deposit: {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} currency={currency} />}\n </span>\n )}\n {cancellationMinutes !== undefined && cancellationMinutes > 0 && (\n <span data-cimplify-booking-cancellation-policy className={classNames?.cancellationPolicy}>\n Free cancellation up to {cancellationMinutes >= 60 ? `${Math.floor(cancellationMinutes / 60)} hour${Math.floor(cancellationMinutes / 60) !== 1 ? "s" : ""}` : `${cancellationMinutes} minute${cancellationMinutes !== 1 ? "s" : ""}`} before\n </span>\n )}\n {noShowFee !== undefined && parsePrice(noShowFee) !== 0 && (\n <span data-cimplify-booking-no-show-fee className={classNames?.cancellationPolicy}>\n No-show fee: <Price amount={noShowFee} currency={currency} />\n </span>\n )}\n </div>\n\n {error && (\n <div data-cimplify-booking-error className={classNames?.error}>\n {error}\n </div>\n )}\n\n {step === STEP.SELECT_SLOT && (\n <div data-cimplify-booking-step="select-slot" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Select a date & time\n </h2>\n <DateSlotPicker\n serviceId={service.id}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n currency={currency}\n />\n </div>\n )}\n\n {step === STEP.SELECT_RESOURCE && resources && (\n <div data-cimplify-booking-step="select-resource" className={cn(classNames?.step, classNames?.resourceStep)}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a room\n </h2>\n <ResourcePicker\n resources={resources}\n selectedResourceId={selectedResourceId}\n onResourceSelect={handleResourceSelect}\n />\n </div>\n )}\n\n {step === STEP.SELECT_STAFF && staff && (\n <div data-cimplify-booking-step="select-staff" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a provider\n </h2>\n <StaffPicker\n staff={staff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={handleStaffSelect}\n />\n </div>\n )}\n\n {step === STEP.CONFIRM && selectedSlot && (\n <div data-cimplify-booking-step="confirm" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Confirm your booking\n </h2>\n <div data-cimplify-booking-summary className={classNames?.summary}>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Service</span>\n <span>{service.name}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Date</span>\n <span>{selectedDate}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Time</span>\n <span>\n {new Date(selectedSlot.start_time).toLocaleTimeString(undefined, {\n hour: "numeric",\n minute: "2-digit",\n })}\n </span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Duration</span>\n <span>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n </div>\n {selectedResourceId && resources && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Room</span>\n <span>{resources.find((r) => r.id === selectedResourceId)?.name ?? "Selected"}</span>\n </div>\n )}\n {selectedStaffId && staff && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Provider</span>\n <span>{staff.find((s) => s.id === selectedStaffId)?.name ?? "Selected"}</span>\n </div>\n )}\n {(selectedSlot.price || service.price) && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Price</span>\n <span>\n <Price amount={selectedSlot.price || service.price!} currency={currency} />\n </span>\n </div>\n )}\n {hasDeposit && (\n <div data-cimplify-booking-summary-row className={cn(classNames?.summaryRow, classNames?.depositInfo)}>\n <span>Deposit</span>\n <span>\n {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} currency={currency} />}\n </span>\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={handleConfirm}\n disabled={isSubmitting}\n data-cimplify-booking-confirm\n className={classNames?.confirmButton}\n >\n {isSubmitting\n ? (isReschedule ? "Rescheduling\u2026" : "Adding to cart\u2026")\n : (isReschedule ? "Reschedule" : "Confirm Booking")}\n </button>\n </div>\n )}\n </div>\n );\n}\n' }] }, "price": { "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [], "files": [{ "path": "price.tsx", "content": '"use client";\n\nimport React from "react";\nimport { formatPrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceProps {\n /** The amount, denominated in `currency` (or the business base currency). */\n amount: number | string;\n /**\n * Currency the `amount` is denominated in. Pass the product/order currency\n * so the value renders correctly during SSR \u2014 before the provider has\n * loaded the business client-side. Falls back to the provider\'s base\n * currency, then USD.\n */\n currency?: CurrencyCode;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. "+"). */\n prefix?: string;\n /**\n * Apply the provider\'s FX display-currency override when one is active.\n * Default true. The conversion is a post-hydration enhancement: it only\n * engages once an FX rate has loaded client-side, so SSR and first-paint\n * output always match and no currency flash occurs.\n */\n convert?: boolean;\n}\n\n/**\n * Price \u2014 renders a formatted price value.\n *\n * Currency is resolved from the `currency` prop (the amount\'s source currency),\n * falling back to the provider\'s base currency, then USD. Because the source\n * currency comes from data rather than the client-only provider bootstrap, an\n * explicit `currency` renders correctly during SSR.\n *\n * FX display-currency conversion (when a user picks a non-base display\n * currency) is layered on top after hydration, and only when the source is the\n * base currency the FX rate is computed from. It never affects the server or\n * first-paint render, so there is no hydration mismatch.\n */\nexport function Price({\n amount,\n currency,\n className,\n prefix,\n convert = true,\n}: PriceProps): React.ReactElement {\n const context = useOptionalCimplify();\n const sourceCurrency =\n currency ?? (context?.baseCurrency as CurrencyCode | undefined) ?? "USD";\n\n const fxActive =\n convert &&\n context?.fxRate != null &&\n context.displayCurrency !== sourceCurrency &&\n sourceCurrency === context.baseCurrency;\n\n const resolvedCurrency = (fxActive ? context!.displayCurrency : sourceCurrency) as CurrencyCode;\n const resolvedAmount = fxActive\n ? context!.convertPrice(amount)\n : typeof amount === "string"\n ? parseFloat(amount) || 0\n : amount;\n\n return (\n <span className={className}>\n {prefix}\n {formatPrice(resolvedAmount, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "recently-viewed": { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recently-viewed.tsx", "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 \u2014 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' }] }, "category-filter": { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-filter.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryFilterClassNames {\n root?: string;\n item?: string;\n allButton?: string;\n count?: string;\n}\n\nexport interface CategoryFilterProps {\n /** Currently selected category ID. Null means "all". */\n selectedId?: string | null;\n /** Called when a category is selected. Null means "all". */\n onSelect: (categoryId: string | null) => void;\n /** Label for the "all" option. Default: "All". */\n allLabel?: string;\n /** Show product counts per category. Default: true. */\n showCounts?: boolean;\n className?: string;\n classNames?: CategoryFilterClassNames;\n}\n\n/** Sentinel value representing the "all" tab (no category filter). */\nconst ALL_VALUE = "__all__";\n\n/**\n * CategoryFilter \u2014 horizontal or vertical list of category chips.\n *\n * Fetches categories via `useCategories` and renders selectable buttons.\n * The parent controls selection state via `selectedId` + `onSelect`.\n *\n * Built on Base UI Tabs for accessible keyboard navigation and ARIA roles.\n */\nexport function CategoryFilter({\n selectedId = null,\n onSelect,\n allLabel = "All",\n showCounts = true,\n className,\n classNames,\n}: CategoryFilterProps): React.ReactElement {\n const { categories, isLoading } = useCategories();\n\n const handleValueChange = useCallback(\n (value: string | number | null) => {\n onSelect(value === ALL_VALUE ? null : String(value));\n },\n [onSelect],\n );\n\n if (isLoading) {\n return (\n <div\n data-cimplify-category-filter\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n return (\n <Tabs.Root\n value={selectedId ?? ALL_VALUE}\n onValueChange={handleValueChange}\n >\n <Tabs.List\n data-cimplify-category-filter\n aria-label="Filter by category"\n className={cn(className, classNames?.root)}\n >\n <Tabs.Tab\n value={ALL_VALUE}\n data-cimplify-category-filter-item\n data-selected={selectedId === null || undefined}\n className={cn(classNames?.item, classNames?.allButton)}\n >\n {allLabel}\n </Tabs.Tab>\n\n {categories.map((category: Category) => (\n <Tabs.Tab\n key={category.id}\n value={category.id}\n data-cimplify-category-filter-item\n data-selected={selectedId === category.id || undefined}\n className={classNames?.item}\n >\n {category.name}\n {showCounts && category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count}\n </span>\n )}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n );\n}\n' }] }, "store-nav": { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "store-nav.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart, useCategories } from "@cimplify/sdk/react";\nimport type { Category } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface StoreNavClassNames {\n root?: string;\n brand?: string;\n categories?: string;\n categoryLink?: string;\n actions?: string;\n cartButton?: string;\n cartCount?: string;\n searchButton?: string;\n}\n\nexport interface StoreNavProps {\n /** Store/brand name. */\n storeName?: string;\n /** Custom brand element (logo, etc.). Overrides storeName. */\n renderBrand?: () => React.ReactNode;\n /** Override categories (skips fetch). */\n categories?: Category[];\n /** Called when a category link is clicked. */\n onCategoryClick?: (category: Category) => void;\n /** Called when the cart button is clicked. */\n onCartClick?: () => void;\n /** Called when the search button is clicked. */\n onSearchClick?: () => void;\n /** Hide category navigation. */\n hideCategories?: boolean;\n /** Hide the cart button. */\n hideCart?: boolean;\n /** Hide the search button. */\n hideSearch?: boolean;\n className?: string;\n classNames?: StoreNavClassNames;\n}\n\n/**\n * StoreNav \u2014 top navigation bar with brand, category links, cart badge, and search.\n *\n * Fetches categories via `useCategories` and cart count via `useCart`.\n * Renders as a semantic `<nav>` element.\n */\nexport function StoreNav({\n storeName,\n renderBrand,\n categories: categoriesProp,\n onCategoryClick,\n onCartClick,\n onSearchClick,\n hideCategories = false,\n hideCart = false,\n hideSearch = false,\n className,\n classNames,\n}: StoreNavProps): React.ReactElement {\n const { categories: fetched } = useCategories({\n enabled: !hideCategories && categoriesProp === undefined,\n });\n const { itemCount } = useCart();\n\n const categories = categoriesProp ?? fetched;\n\n return (\n <nav data-cimplify-store-nav className={cn(className, classNames?.root)}>\n {/* Brand */}\n <div data-cimplify-store-nav-brand className={classNames?.brand}>\n {renderBrand ? renderBrand() : storeName && <span>{storeName}</span>}\n </div>\n\n {/* Category links */}\n {!hideCategories && categories.length > 0 && (\n <div data-cimplify-store-nav-categories className={classNames?.categories}>\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onCategoryClick?.(category)}\n data-cimplify-store-nav-category\n className={classNames?.categoryLink}\n >\n {category.name}\n </button>\n ))}\n </div>\n )}\n\n {/* Actions */}\n <div data-cimplify-store-nav-actions className={classNames?.actions}>\n {!hideSearch && (\n <button\n type="button"\n onClick={onSearchClick}\n data-cimplify-store-nav-search\n className={classNames?.searchButton}\n aria-label="Search"\n >\n Search\n </button>\n )}\n\n {!hideCart && (\n <button\n type="button"\n onClick={onCartClick}\n data-cimplify-store-nav-cart\n className={classNames?.cartButton}\n aria-label={`Cart (${itemCount} items)`}\n >\n Cart\n {itemCount > 0 && (\n <span data-cimplify-store-nav-cart-count className={classNames?.cartCount}>\n {itemCount}\n </span>\n )}\n </button>\n )}\n </div>\n </nav>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} currency={currency} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} currency={currency} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} currency={currency} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "location-picker": { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "location-picker.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Location } from "../types/business";\nimport { useLocations } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface LocationPickerClassNames {\n root?: string;\n item?: string;\n activeItem?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface LocationPickerProps {\n /** Override locations (skips useLocations fetch). For SSR, pass pre-fetched locations. */\n locations?: Location[];\n /** Override the current location. */\n currentLocation?: Location | null;\n /** Called when a location is selected. */\n onLocationChange?: (location: Location) => void;\n /** Custom location renderer. */\n renderLocation?: (location: Location, isActive: boolean) => React.ReactNode;\n /** Text shown when no locations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: LocationPickerClassNames;\n}\n\n/**\n * LocationPicker \u2014 renders a location selector for multi-location businesses.\n *\n * Uses `useLocations` to fetch locations unless overridden via props.\n * Returns `null` when there is only one location (no picker needed).\n */\nexport function LocationPicker({\n locations: locationsProp,\n currentLocation: currentLocationProp,\n onLocationChange,\n renderLocation,\n emptyMessage,\n className,\n classNames,\n}: LocationPickerProps): React.ReactElement | null {\n const {\n locations: fetched,\n currentLocation: fetchedCurrent,\n setCurrentLocation,\n isLoading,\n } = useLocations();\n\n const locations = locationsProp ?? fetched;\n const currentLocation = currentLocationProp ?? fetchedCurrent;\n\n const handleSelect = (location: Location): void => {\n if (onLocationChange) {\n onLocationChange(location);\n } else {\n setCurrentLocation(location);\n }\n };\n\n if (isLoading && locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n // No picker needed for single location\n if (locations.length <= 1) {\n return null;\n }\n\n if (locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No locations available"}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-location-picker className={cn(className, classNames?.root)}>\n {locations.map((location) => {\n const isActive = currentLocation?.id === location.id;\n return (\n <button\n key={location.id}\n type="button"\n onClick={() => handleSelect(location)}\n data-cimplify-location-item\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.item, isActive && classNames?.activeItem)}\n >\n {renderLocation ? (\n renderLocation(location, isActive)\n ) : (\n <span>{location.name}</span>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "rich-text": { "name": "rich-text", "title": "RichText", "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "rich-text.tsx", "content": '"use client";\n\nimport React, { useMemo } from "react";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from "xss";\nimport { cn } from "@cimplify/sdk/react";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n "www.youtube.com",\n "youtube.com",\n "www.youtube-nocookie.com",\n "youtube-nocookie.com",\n "player.vimeo.com",\n]);\n\n// Fixed permission policy for embeds. Merchant-supplied `allow` is discarded\n// and replaced with this \u2014 a description must never request camera/mic/etc.\nconst IFRAME_ALLOW =\n "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen";\n\n// `class` is an unrestricted CSS channel in a Tailwind storefront \u2014 a merchant\n// class like `fixed inset-0 z-50` re-enables exactly what the inline-CSS filter\n// below forbids. So `class` values are filtered to this namespace: only the\n// SDK\'s own `cimplify-*` layout primitives survive; everything else is dropped.\nconst SAFE_CLASS_PATTERN = /^cimplify-[a-z0-9-]+$/i;\n\n// Inline CSS properties we permit (via the xss css filter). Anything else \u2014\n// position, z-index, behaviours, url() exfil vectors \u2014 is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n "text-align": true,\n color: true,\n "background-color": true,\n "font-weight": true,\n "font-style": true,\n "text-decoration": true,\n "vertical-align": true,\n width: true,\n height: true,\n};\n\nconst STYLEABLE = ["style", "class"];\n\n/**\n * Allowlist covering rich product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n p: STYLEABLE,\n br: [],\n div: STYLEABLE,\n section: ["class"],\n span: STYLEABLE,\n strong: [],\n b: [],\n em: [],\n i: [],\n u: [],\n s: [],\n strike: [],\n del: [],\n ins: [],\n mark: [],\n small: [],\n sub: [],\n sup: [],\n h1: STYLEABLE,\n h2: STYLEABLE,\n h3: STYLEABLE,\n h4: STYLEABLE,\n h5: STYLEABLE,\n h6: STYLEABLE,\n ul: ["class"],\n ol: ["start", "type", "class"],\n li: ["class"],\n dl: ["class"],\n dt: ["class"],\n dd: ["class"],\n blockquote: ["class"],\n code: [],\n pre: ["class"],\n hr: [],\n a: ["href", "title", "target", "rel"],\n img: ["src", "alt", "title", "width", "height", "loading", "class", "style"],\n figure: ["class"],\n figcaption: ["class"],\n details: [],\n summary: [],\n table: ["class", "style"],\n thead: [],\n tbody: [],\n tfoot: [],\n caption: [],\n colgroup: [],\n col: ["span", "style"],\n tr: ["class"],\n th: ["colspan", "rowspan", "scope", "align", "class", "style"],\n td: ["colspan", "rowspan", "align", "class", "style"],\n iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "loading"],\n};\n\nfunction safeAttrValue(\n tag: string,\n name: string,\n value: string,\n cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n if (tag === "iframe" && name === "src") {\n try {\n const url = new URL(value);\n if (url.protocol === "https:" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n return value;\n }\n } catch {\n // malformed URL \u2014 fall through to blank\n }\n return "";\n }\n if (tag === "iframe" && name === "allow") {\n return IFRAME_ALLOW;\n }\n if (name === "class") {\n return value\n .split(/\\s+/)\n .filter((token) => SAFE_CLASS_PATTERN.test(token))\n .join(" ");\n }\n return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// Reverse-tabnabbing guard: force `rel="noopener noreferrer"` on any\n// `target="_blank"` anchor. Runs after FILTER.process, whose output has already\n// escaped attribute values \u2014 so no raw `>` can appear inside an attribute and a\n// tag-level regex is safe. Deterministic (identical SSR + client).\nfunction withLinkSafety(html: string): string {\n return html.replace(/<a\\b[^>]*>/gi, (tag) => {\n if (!/\\btarget\\s*=\\s*["\']_blank["\']/i.test(tag)) return tag;\n return /\\brel\\s*=\\s*["\'][^"\']*["\']/i.test(tag)\n ? tag.replace(/\\brel\\s*=\\s*["\'][^"\']*["\']/i, \'rel="noopener noreferrer"\')\n : tag.replace(/>$/, \' rel="noopener noreferrer">\');\n });\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n whiteList: RICH_TEXT_WHITELIST,\n css: { whiteList: ALLOWED_CSS_PROPERTIES },\n safeAttrValue,\n stripIgnoreTag: true,\n stripIgnoreTagBody: ["script", "style"],\n allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser \u2014 no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n return withLinkSafety(FILTER.process(html));\n}\n\n// Theme-aware prose styling. Wired to the consumer\'s own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront\'s type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n "text-muted-foreground leading-relaxed",\n "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",\n "[&_p]:my-3",\n "[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground",\n "[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground",\n "[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground",\n "[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground",\n "[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground",\n "[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground",\n "[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground",\n "[&_em]:italic [&_i]:italic",\n "[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through",\n "[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]",\n "[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",\n "[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1",\n "[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1",\n "[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]",\n "[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4",\n "[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic",\n "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",\n "[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]",\n "[&_pre>code]:bg-transparent [&_pre>code]:p-0",\n "[&_hr]:my-4 [&_hr]:border-border",\n "[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground",\n "[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md",\n "[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3",\n "[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground",\n "[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm",\n "[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground",\n "[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground",\n "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top",\n "[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md",\n);\n\nexport interface RichTextProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML" | "children"> {\n /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n html?: string | null;\n}\n\n/**\n * RichText \u2014 renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there\'s no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : ""), [html]);\n\n if (!clean) {\n return null;\n }\n\n return (\n <div\n data-cimplify-rich-text\n className={cn(RICH_TEXT_PROSE, className)}\n dangerouslySetInnerHTML={{ __html: clean }}\n {...rest}\n />\n );\n}\n' }] }, "sale-badge": { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "sale-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductDealInfo } from "@cimplify/sdk";\nimport type { ProductWithPrice } from "@cimplify/sdk";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n getProductCurrency,\n parsePrice,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge \u2014 shows a sale/discount indicator for a product.\n *\n * Returns `null` when there\'s no deal, no sale price difference, and no label override,\n * so it\'s safe to render unconditionally \u2014 it simply won\'t show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n // Percentage: prefer dealInfo when it\'s a percentage benefit, else compute from prices\n let percentage: number | null = null;\n if (hasDeal && dealInfo.benefit_type === "percentage") {\n percentage = parsePrice(dealInfo.value);\n } else if (onSale) {\n percentage = getDiscountPercentage(product);\n }\n\n // Badge text: explicit label > deal label > computed "X% off"\n const badgeText =\n label ??\n dealInfo?.label ??\n (percentage != null && percentage > 0 ? `${percentage}% off` : null);\n\n if (!badgeText) {\n return null;\n }\n\n return (\n <span\n data-cimplify-sale-badge\n className={cn(className, classNames?.root)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n {showPercentage && percentage != null && percentage > 0 && (\n <span data-cimplify-sale-percentage className={classNames?.percentage}>\n -{percentage}%\n </span>\n )}\n <span data-cimplify-sale-label className={classNames?.label}>\n {badgeText}\n </span>\n {showOriginalPrice && onSale && (\n <span data-cimplify-sale-original-price className={classNames?.originalPrice}>\n <Price amount={getBasePrice(product)} currency={getProductCurrency(product)} />\n </span>\n )}\n </span>\n );\n}\n' }] }, "booking-card": { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "booking-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking, BookingStatus } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingCardClassNames {\n root?: string;\n service?: string;\n status?: string;\n time?: string;\n confirmationCode?: string;\n total?: string;\n actions?: string;\n cancelButton?: string;\n rescheduleButton?: string;\n}\n\nexport interface BookingCardProps {\n /** The booking to display. */\n booking: CustomerBooking;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Custom renderer for the booking. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingCardClassNames;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n pending: "Pending",\n confirmed: "Confirmed",\n in_progress: "In Progress",\n completed: "Completed",\n cancelled: "Cancelled",\n no_show: "No Show",\n};\n\nfunction isActiveStatus(status: BookingStatus): boolean {\n const s = String(status).toLowerCase();\n return s !== "completed" && s !== "cancelled" && s !== "no_show";\n}\n\nfunction getFirstServiceItem(booking: CustomerBooking) {\n return booking.service_items[0] ?? null;\n}\n\nfunction formatScheduledTime(start?: string | null, end?: string | null): string | null {\n if (!start) return null;\n try {\n const startDate = new Date(start);\n const options: Intl.DateTimeFormatOptions = {\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n };\n let formatted = startDate.toLocaleString(undefined, options);\n if (end) {\n const endDate = new Date(end);\n formatted += ` \u2013 ${endDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;\n }\n return formatted;\n } catch {\n return start;\n }\n}\n\nexport function BookingCard({\n booking,\n onCancel,\n onReschedule,\n renderBooking,\n className,\n classNames,\n}: BookingCardProps): React.ReactElement {\n if (renderBooking) {\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(className, classNames?.root)}\n >\n {renderBooking(booking)}\n </div>\n );\n }\n\n const firstItem = getFirstServiceItem(booking);\n const active = isActiveStatus(booking.status);\n const scheduledTime = firstItem\n ? formatScheduledTime(firstItem.scheduled_start, firstItem.scheduled_end)\n : null;\n\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(\n "rounded-xl border border-border bg-background p-4 flex flex-col gap-3",\n className,\n classNames?.root,\n )}\n >\n <div data-cimplify-booking-main className="flex items-center justify-between gap-2">\n <span data-cimplify-booking-service className={cn("font-semibold text-foreground", classNames?.service)}>\n Booking #{booking.order_id.slice(0, 8)}\n </span>\n <span\n data-cimplify-booking-status\n data-status={booking.status}\n className={cn(\n "text-xs font-medium px-2 py-0.5 rounded-full",\n active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",\n classNames?.status,\n )}\n >\n {STATUS_LABELS[booking.status] ?? booking.status}\n </span>\n </div>\n\n {firstItem?.confirmation_code && (\n <span data-cimplify-booking-code className={cn("text-xs font-mono text-muted-foreground tracking-wide", classNames?.confirmationCode)}>\n {firstItem.confirmation_code}\n </span>\n )}\n\n {scheduledTime && (\n <time\n data-cimplify-booking-time\n dateTime={firstItem?.scheduled_start ?? undefined}\n className={cn("text-sm text-foreground", classNames?.time)}\n >\n {scheduledTime}\n </time>\n )}\n\n <span data-cimplify-booking-total className={cn("text-sm font-medium text-foreground", classNames?.total)}>\n <Price amount={booking.total_price} />\n </span>\n\n {active && (onCancel || onReschedule) && (\n <div data-cimplify-booking-actions className={cn("flex gap-2 pt-1 border-t border-border mt-1", classNames?.actions)}>\n {onReschedule && (\n <button\n type="button"\n onClick={() => onReschedule(booking)}\n data-cimplify-booking-reschedule\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted cursor-pointer",\n classNames?.rescheduleButton,\n )}\n >\n Reschedule\n </button>\n )}\n {onCancel && (\n <button\n type="button"\n onClick={() => onCancel(booking)}\n data-cimplify-booking-cancel\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-destructive transition-colors hover:bg-destructive/10 cursor-pointer",\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n )}\n </div>\n )}\n </div>\n );\n}\n' }] }, "discount-input": { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "discount-input.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport { useValidateDiscount } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DiscountInputClassNames {\n root?: string;\n input?: string;\n button?: string;\n result?: string;\n error?: string;\n success?: string;\n}\n\nexport interface DiscountInputProps {\n /** Current order subtotal for validation. */\n orderSubtotal: string;\n /** Location ID for location-specific discounts. */\n locationId?: string;\n /** Called when a valid discount is applied. */\n onApply?: (validation: DiscountValidation) => void;\n /** Called when discount is cleared. */\n onClear?: () => void;\n /** Placeholder text. */\n placeholder?: string;\n className?: string;\n classNames?: DiscountInputClassNames;\n}\n\n/**\n * DiscountInput \u2014 discount code input with inline validation.\n *\n * Wraps `useValidateDiscount` with a Base UI Field + Input and apply button.\n * Shows validation result inline (success with amount, or error via Field.Error).\n */\nexport function DiscountInput({\n orderSubtotal,\n locationId,\n onApply,\n onClear,\n placeholder = "Discount code",\n className,\n classNames,\n}: DiscountInputProps): React.ReactElement {\n const [code, setCode] = useState("");\n const [appliedValidation, setAppliedValidation] =\n useState<DiscountValidation | null>(null);\n const { validate, isValidating, error } = useValidateDiscount();\n\n const handleApply = useCallback(async () => {\n const trimmed = code.trim();\n if (!trimmed) return;\n\n const result = await validate(trimmed, orderSubtotal, locationId);\n if (result) {\n if (result.is_eligible) {\n setAppliedValidation(result);\n onApply?.(result);\n } else {\n setAppliedValidation(result);\n }\n }\n }, [code, validate, orderSubtotal, locationId, onApply]);\n\n const handleClear = useCallback(() => {\n setCode("");\n setAppliedValidation(null);\n onClear?.();\n }, [onClear]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === "Enter") {\n void handleApply();\n }\n },\n [handleApply],\n );\n\n const isApplied = appliedValidation?.is_eligible === true;\n const hasError =\n !!error || (!!appliedValidation && !appliedValidation.is_eligible);\n\n const errorMessage = error\n ? error.message\n : appliedValidation && !appliedValidation.is_eligible\n ? (appliedValidation.ineligibility_reason ?? "This code is not valid.")\n : undefined;\n\n return (\n <Field.Root\n data-cimplify-discount\n invalid={hasError}\n disabled={isApplied}\n className={cn(className, classNames?.root)}\n >\n <div data-cimplify-discount-form>\n <Input\n type="text"\n value={code}\n onValueChange={(value) => setCode(value)}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n data-cimplify-discount-input\n className={classNames?.input}\n aria-label="Discount code"\n />\n {isApplied ? (\n <button\n type="button"\n onClick={handleClear}\n data-cimplify-discount-clear\n className={classNames?.button}\n >\n Remove\n </button>\n ) : (\n <button\n type="button"\n onClick={handleApply}\n disabled={isValidating || code.trim().length === 0}\n data-cimplify-discount-apply\n className={classNames?.button}\n >\n {isValidating ? "Checking..." : "Apply"}\n </button>\n )}\n </div>\n\n {hasError && errorMessage && (\n <Field.Error\n match={true}\n data-cimplify-discount-error\n className={classNames?.error}\n >\n {errorMessage}\n </Field.Error>\n )}\n\n {isApplied && appliedValidation.discount_amount && (\n <Field.Description\n data-cimplify-discount-success\n className={classNames?.success}\n >\n <span>Discount applied</span>\n <Price amount={appliedValidation.discount_amount} prefix="-" />\n </Field.Description>\n )}\n </Field.Root>\n );\n}\n' }] }, "add-on-selector": { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "add-on-selector.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport type { AddOnWithOptions } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n currency?: CurrencyCode;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n currency,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const selectedSet = useMemo(() => new Set(selectedOptions), [selectedOptions]);\n\n const isOptionSelected = useCallback(\n (optionId: string) => selectedSet.has(optionId),\n [selectedSet],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedSet.has(optionId);\n\n if (addOn.is_mutually_exclusive || !addOn.is_multiple_allowed) {\n const groupOptionIds = new Set(addOn.options.map((o) => o.id));\n const withoutGroup = selectedOptions.filter((id) => !groupOptionIds.has(id));\n\n if (isSelected) {\n if (!addOn.is_required) {\n onOptionsChange(withoutGroup);\n }\n } else {\n onOptionsChange([...withoutGroup, optionId]);\n }\n } else {\n if (isSelected) {\n onOptionsChange(selectedOptions.filter((id) => id !== optionId));\n } else {\n let currentCount = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentCount += 1;\n }\n }\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, selectedSet, onOptionsChange],\n );\n\n if (!addOns || addOns.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-addon-selector className={cn("space-y-6", className, classNames?.root)}>\n {addOns.map((addOn) => {\n let currentSelections = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentSelections += 1;\n }\n }\n const minMet = !addOn.min_selections || currentSelections >= addOn.min_selections;\n const isSingleSelect = addOn.is_mutually_exclusive || !addOn.is_multiple_allowed;\n\n return (\n <div\n key={addOn.id}\n data-cimplify-addon-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-addon-header\n className={cn("flex items-center justify-between py-3", classNames?.header)}\n >\n <div>\n <span\n data-cimplify-addon-name\n className={cn("text-base font-bold", classNames?.name)}\n >\n {addOn.name}\n </span>\n {(addOn.min_selections || addOn.max_selections) && (\n <span\n data-cimplify-addon-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.constraint)}\n >\n {addOn.min_selections && addOn.max_selections\n ? `Choose ${addOn.min_selections}\\u2013${addOn.max_selections}`\n : addOn.min_selections\n ? `Choose at least ${addOn.min_selections}`\n : `Choose up to ${addOn.max_selections}`}\n </span>\n )}\n </div>\n {(addOn.is_required || !minMet) && (\n <span\n data-cimplify-addon-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-addon-options\n className={cn("divide-y divide-border", classNames?.options)}\n >\n {addOn.options.map((option) => {\n const isSelected = isOptionSelected(option.id);\n\n return (\n <Checkbox.Root\n key={option.id}\n checked={isSelected}\n onCheckedChange={(checked) =>\n handleCheckedChange(addOn, option.id, checked)\n }\n value={option.id}\n data-cimplify-addon-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-addon-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-addon-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <span\n data-cimplify-addon-option-name\n className={cn(\n "flex-1 min-w-0 text-sm",\n classNames?.optionName,\n )}\n >\n {option.name}\n </span>\n\n {option.default_price != null && (\n <span className="text-sm text-muted-foreground">\n +<Price amount={option.default_price} currency={currency} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n' }] }, "billing-plan-selector": { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "billing-plan-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductBillingPlan, BillingFrequency } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useBillingPlans } from "./hooks/use-billing-plans";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BillingPlanSelectorClassNames {\n root?: string;\n plan?: string;\n activePlan?: string;\n frequency?: string;\n price?: string;\n setupFee?: string;\n trialBadge?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface BillingPlanSelectorProps {\n /** Product ID to fetch billing plans for. */\n productId: string;\n /** Override plans (skips fetch). For SSR, pass pre-fetched plans. */\n plans?: ProductBillingPlan[];\n /** Called when a plan is selected. */\n onPlanSelect?: (plan: ProductBillingPlan | null) => void;\n /** Currently selected plan ID. */\n selectedPlanId?: string | null;\n /** Currency the plan amounts are denominated in. */\n currency?: CurrencyCode;\n /** Show a "One-time purchase" option at the top. */\n showOneTimePurchase?: boolean;\n /** Label for the one-time purchase option. Default: "One-time purchase". */\n oneTimePurchaseLabel?: string;\n /** Custom plan card renderer. */\n renderPlan?: (plan: ProductBillingPlan, isActive: boolean) => React.ReactNode;\n className?: string;\n classNames?: BillingPlanSelectorClassNames;\n}\n\nconst FREQUENCY_LABELS: Record<BillingFrequency, string> = {\n weekly: "Weekly",\n biweekly: "Biweekly",\n monthly: "Monthly",\n quarterly: "Quarterly",\n annually: "Annually",\n};\n\nfunction formatMarkup(plan: ProductBillingPlan): string | null {\n if (!plan.markup_type || plan.markup_amount == null) return null;\n if (plan.markup_type === "percentage") return `+${plan.markup_amount}%`;\n return null;\n}\n\n/**\n * BillingPlanSelector \u2014 subscription/installment plan comparison and selection.\n *\n * Renders plan cards with frequency, markup, trial, and setup fee details.\n * Returns `null` when no plans are available (and one-time purchase is hidden).\n */\nexport function BillingPlanSelector({\n productId,\n plans: plansProp,\n currency,\n onPlanSelect,\n selectedPlanId,\n showOneTimePurchase = false,\n oneTimePurchaseLabel = "One-time purchase",\n renderPlan,\n className,\n classNames,\n}: BillingPlanSelectorProps): React.ReactElement | null {\n const { plans: fetched, isLoading } = useBillingPlans(productId, {\n enabled: plansProp === undefined,\n });\n\n const plans = plansProp ?? fetched;\n\n if (isLoading && plans.length === 0) {\n return (\n <div\n data-cimplify-billing-plan-selector\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (plans.length === 0 && !showOneTimePurchase) {\n return null;\n }\n\n const isOneTimePurchaseSelected = selectedPlanId === null;\n\n return (\n <div data-cimplify-billing-plan-selector className={cn(className, classNames?.root)}>\n {showOneTimePurchase && (\n <button\n type="button"\n onClick={() => onPlanSelect?.(null)}\n data-cimplify-billing-plan\n data-plan-type="one-time"\n data-active={isOneTimePurchaseSelected || undefined}\n aria-pressed={isOneTimePurchaseSelected}\n className={cn(\n classNames?.plan,\n isOneTimePurchaseSelected && classNames?.activePlan,\n )}\n >\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {oneTimePurchaseLabel}\n </span>\n </button>\n )}\n\n {plans.map((plan) => {\n const isActive = selectedPlanId === plan.id;\n const markup = formatMarkup(plan);\n\n return (\n <button\n key={plan.id}\n type="button"\n onClick={() => onPlanSelect?.(plan)}\n data-cimplify-billing-plan\n data-plan-type={plan.plan_type}\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.plan, isActive && classNames?.activePlan)}\n >\n {renderPlan ? (\n renderPlan(plan, isActive)\n ) : (\n <>\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {FREQUENCY_LABELS[plan.frequency]}\n </span>\n\n {markup && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n {markup}\n </span>\n )}\n\n {plan.markup_type === "fixed" && plan.markup_amount != null && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n +<Price amount={plan.markup_amount} currency={currency} />\n </span>\n )}\n\n {plan.trial_days > 0 && (\n <span data-cimplify-billing-trial className={classNames?.trialBadge}>\n {plan.trial_days}-day trial\n </span>\n )}\n\n {plan.setup_fee > 0 && (\n <span data-cimplify-billing-setup-fee className={classNames?.setupFee}>\n Setup fee: <Price amount={plan.setup_fee} currency={currency} />\n </span>\n )}\n\n {plan.plan_type === "installment" && plan.installment_periods != null && (\n <span data-cimplify-billing-periods>\n {plan.installment_periods} payments\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "accommodation-card": { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn", "strip-html"], "files": [{ "path": "cards/accommodation-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function AccommodationCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per night\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} currency={currency} />/night\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n {/* Amenity / tag pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Guest capacity */}\n {product.general_service_capacity != null && (\n <div className="flex items-center gap-1.5 mt-2 text-[11px] text-muted-foreground">\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />\n </svg>\n {product.general_service_capacity} guest{product.general_service_capacity > 1 ? "s" : ""}\n </div>\n )}\n\n {/* Extended stay pricing */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {cancellationHours != null && (\n <span className="text-emerald-600 font-medium">\n Free cancellation {cancellationHours}h\n </span>\n )}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span>Min. {product.min_order_quantity} nights</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book room →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "booking-list": { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"], "files": [{ "path": "booking-list.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { useBookings } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingListClassNames {\n root?: string;\n item?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface BookingListProps {\n /** Pre-fetched bookings (skips fetch). */\n bookings?: CustomerBooking[];\n /** Filter: "all", "upcoming", or "past". */\n filter?: "all" | "upcoming" | "past";\n /** Called when cancel is clicked on a booking. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked on a booking. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Called when a booking is clicked. */\n onBookingClick?: (booking: CustomerBooking) => void;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: BookingListClassNames;\n}\n\nexport function BookingList({\n bookings: bookingsProp,\n filter,\n onCancel,\n onReschedule,\n onBookingClick,\n renderBooking,\n emptyMessage = "No bookings yet",\n className,\n classNames,\n}: BookingListProps): React.ReactElement {\n const { bookings: fetched, isLoading } = useBookings({\n filter,\n enabled: bookingsProp === undefined,\n });\n\n const bookings = bookingsProp ?? fetched;\n\n if (isLoading && bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-booking-list className={cn(className, classNames?.root)}>\n {bookings.map((booking) => (\n <div\n key={booking.order_id}\n data-cimplify-booking-list-item\n className={classNames?.item}\n onClick={() => onBookingClick?.(booking)}\n role={onBookingClick ? "button" : undefined}\n tabIndex={onBookingClick ? 0 : undefined}\n onKeyDown={\n onBookingClick\n ? (e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault();\n onBookingClick(booking);\n }\n }\n : undefined\n }\n >\n <BookingCard\n booking={booking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n renderBooking={renderBooking}\n />\n </div>\n ))}\n </div>\n );\n}\n' }] }, "composite-selector": { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "composite-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport { NumberField } from "@base-ui/react/number-field";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n currency?: CurrencyCode;\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n currency,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplifyClient();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n const sortedGroups = useMemo(\n () =>\n [...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => ({\n ...group,\n _sortedComponents: group.components\n .filter((component) => component.is_available && !component.is_archived)\n .sort((a, b) => a.display_order - b.display_order),\n })),\n [groups],\n );\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn("space-y-6", className, classNames?.root)}>\n {sortedGroups.map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn("flex items-center justify-between py-3", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn("text-base font-bold", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : "Choose as many as you like"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded shrink-0",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? "radiogroup" : "group"}\n aria-label={group.name}\n className={cn("divide-y divide-border", classNames?.components)}\n >\n {group._sortedComponents.map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn("flex-1 min-w-0", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-composite-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-composite-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n {component.is_popular && (\n <span\n data-cimplify-composite-badge="popular"\n className={cn("text-[10px] uppercase tracking-wider text-primary font-medium", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge="premium"\n className={cn("text-[10px] uppercase tracking-wider text-amber-600 font-medium", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn("block text-xs text-muted-foreground/60", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn("flex items-center gap-2", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn("w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className="text-sm text-muted-foreground shrink-0">\n +<Price amount={component.price} currency={currency} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn("border-t border-border pt-4 space-y-1 text-sm", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} currency={currency} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} currency={currency} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn("flex justify-between font-medium pt-1 border-t border-border", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} currency={currency} className="text-primary" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn("flex items-center gap-2 text-sm text-muted-foreground", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn("text-sm text-destructive", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n' }] }, "product-image-gallery": { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-image-gallery.tsx", "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 \u2014 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' }] }, "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] } };
|
|
6453
6453
|
var REGISTRY_INDEX = { "components": [{ "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [] }, { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [] }, { "name": "variant-selector", "title": "VariantSelector", "description": "Select product variants via axis chips or direct list.", "type": "component", "registryDependencies": ["price"] }, { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"] }, { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"] }, { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"] }, { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"] }, { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [] }, { "name": "store-video", "title": "StoreVideo", "description": "Autoplay-muted-loop video for storefront heroes / product demos. Lazy-loaded, mobile-safe (playsInline), poster fallback for browsers without video.", "type": "component", "registryDependencies": [] }, { "name": "product-model-3d", "title": "ProductModel3D", "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.", "type": "component", "registryDependencies": [] }, { "name": "media-gallery", "title": "MediaGallery", "description": "Like ProductImageGallery but accepts mixed image/video/3D items in one carousel. Thumbnails show a poster + small overlay icon for video/3D.", "type": "component", "registryDependencies": ["store-video", "product-model-3d"] }, { "name": "cart-summary", "title": "CartSummary", "description": "Cart line items with quantity controls and totals.", "type": "component", "registryDependencies": ["price", "quantity-selector"] }, { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"] }, { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "rich-text", "title": "RichText", "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.", "type": "component", "registryDependencies": ["cn"] }, { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "rich-text", "cn"] }, { "name": "product-card", "title": "ProductCard", "description": "Product display card with modal or link mode.", "type": "component", "registryDependencies": ["price", "product-sheet", "cn", "strip-html"] }, { "name": "product-grid", "title": "ProductGrid", "description": "Responsive CSS grid that renders ProductCards.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "cn", "title": "cn", "description": "Utility that merges Tailwind CSS classes with clsx + tailwind-merge.", "type": "utility", "registryDependencies": [] }, { "name": "strip-html", "title": "stripHtmlToText", "description": "Projects HTML to a single-line plain-text preview for cards and meta descriptions. Not a security boundary \u2014 output is rendered as text only.", "type": "utility", "registryDependencies": [] }, { "name": "search-input", "title": "SearchInput", "description": "Search bar with debounced results dropdown.", "type": "component", "registryDependencies": ["cn"] }, { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"] }, { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "category-grid", "title": "CategoryGrid", "description": "Responsive grid of category cards.", "type": "component", "registryDependencies": ["cn"] }, { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "order-summary", "title": "OrderSummary", "description": "Single order detail view with line items and totals.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"] }, { "name": "catalogue-page", "title": "CataloguePage", "description": "Browse all products with category filtering and search.", "type": "component", "registryDependencies": ["product-grid", "category-filter", "search-input", "cn"] }, { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"] }, { "name": "cart-page", "title": "CartPage", "description": "Full-page cart with summary, discount input, and checkout.", "type": "component", "registryDependencies": ["cart-summary", "discount-input", "cn"] }, { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"] }, { "name": "collection-page", "title": "CollectionPage", "description": "Curated product collection with header and grid.", "type": "component", "registryDependencies": ["product-grid", "cn"] }, { "name": "order-detail-page", "title": "OrderDetailPage", "description": "Single order detail view with live status polling.", "type": "component", "registryDependencies": ["order-summary", "cn"] }, { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"] }, { "name": "search-page", "title": "SearchPage", "description": "Dedicated search page with input and results grid.", "type": "component", "registryDependencies": ["product-grid", "cn"] }, { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"] }, { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "date-slot-picker", "title": "DateSlotPicker", "description": "Horizontal date strip with slot picker for service scheduling.", "type": "component", "registryDependencies": ["slot-picker", "cn"] }, { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"] }, { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"] }, { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"] }, { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"] }, { "name": "food-product-card", "title": "FoodProductCard", "description": "Product card for food items with tags, badges, and quick-add.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "retail-product-card", "title": "RetailProductCard", "description": "Product card for retail with color swatches, sale badge, wishlist, and sold-out overlay.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"] }, { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn", "strip-html"] }, { "name": "lease-service-card", "title": "LeaseServiceCard", "description": "Long-term lease card with per-month/year pricing, volume tiers, and billing.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn", "strip-html"] }, { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"] }, { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "price-range", "title": "PriceRange", "description": "Displays min-max price range for products with variants or tiers.", "type": "component", "registryDependencies": [] }, { "name": "default-product-layout", "title": "DefaultProductLayout", "description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "availability-badge", "sale-badge", "rich-text", "cn"] }, { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"] }, { "name": "wholesale-product-layout", "title": "WholesaleProductLayout", "description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "volume-pricing", "rich-text", "cn"] }, { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"] }, { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"] }, { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"] }, { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"] }, { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "currency-selector", "title": "CurrencySelector", "description": "Multi-currency switcher backed by the FX provider \u2014 locks display currency and quote ID.", "type": "component", "registryDependencies": ["cn"] }, { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"] }, { "name": "delivery-estimate", "title": "DeliveryEstimate", "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"] }, { "name": "resource-picker", "title": "ResourcePicker", "description": "Staff / room / resource picker for bookable services \u2014 used by services and restaurant reservation flows.", "type": "component", "registryDependencies": ["cn"] }, { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "recommendation-carousel", "title": "RecommendationCarousel", "description": "Personalized product carousel powered by /activity/recommendations \u2014 frequently bought, related, trending.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "session-message-banner", "title": "SessionMessageBanner", "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.", "type": "component", "registryDependencies": ["cn"] }] };
|
|
6454
6454
|
|
|
6455
6455
|
export { REGISTRY, REGISTRY_INDEX, TEMPLATES };
|