@cimplify/sdk 0.44.11 → 0.44.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/mock/cli.mjs +171 -23
  2. package/dist/mock/library.d.mts +2 -2
  3. package/dist/mock/library.d.ts +2 -2
  4. package/dist/mock/library.js +171 -23
  5. package/dist/mock/library.mjs +171 -23
  6. package/dist/mock/msw.d.mts +1 -1
  7. package/dist/mock/msw.d.ts +1 -1
  8. package/dist/mock/msw.js +171 -23
  9. package/dist/mock/msw.mjs +171 -23
  10. package/dist/{server-CUbuQxOV.d.mts → server-CnECAQDQ.d.mts} +28 -6
  11. package/dist/{server-CUbuQxOV.d.ts → server-CnECAQDQ.d.ts} +28 -6
  12. package/package.json +1 -1
  13. package/registry/account.json +15 -0
  14. package/registry/billing-plan-selector.json +16 -0
  15. package/registry/cart-drawer.json +17 -0
  16. package/registry/currency-selector.json +15 -0
  17. package/registry/customer-input-fields.json +15 -0
  18. package/registry/delivery-estimate.json +16 -0
  19. package/registry/index.json +105 -0
  20. package/registry/location-picker.json +15 -0
  21. package/registry/recently-viewed.json +16 -0
  22. package/registry/recommendation-carousel.json +16 -0
  23. package/registry/resource-picker.json +15 -0
  24. package/registry/session-message-banner.json +15 -0
  25. package/templates/storefront-bakery/AGENTS.md +12 -0
  26. package/templates/storefront-bakery/app/layout.tsx +2 -0
  27. package/templates/storefront-bakery/components/cart-drawer.tsx +14 -0
  28. package/templates/storefront-bakery/components/cart-pill.tsx +11 -7
  29. package/templates/storefront-bakery/components/providers.tsx +6 -2
  30. package/templates/storefront-fashion/AGENTS.md +21 -1
  31. package/templates/storefront-grocery/AGENTS.md +12 -0
  32. package/templates/storefront-grocery/app/layout.tsx +2 -0
  33. package/templates/storefront-grocery/components/cart-drawer.tsx +14 -0
  34. package/templates/storefront-grocery/components/cart-pill.tsx +11 -7
  35. package/templates/storefront-grocery/components/providers.tsx +6 -2
  36. package/templates/storefront-restaurant/AGENTS.md +12 -0
  37. package/templates/storefront-restaurant/app/layout.tsx +2 -0
  38. package/templates/storefront-restaurant/components/cart-drawer.tsx +14 -0
  39. package/templates/storefront-restaurant/components/cart-pill.tsx +11 -7
  40. package/templates/storefront-restaurant/components/providers.tsx +6 -2
  41. package/templates/storefront-retail/AGENTS.md +12 -0
  42. package/templates/storefront-retail/app/layout.tsx +2 -0
  43. package/templates/storefront-retail/components/cart-drawer.tsx +14 -0
  44. package/templates/storefront-retail/components/cart-pill.tsx +11 -7
  45. package/templates/storefront-retail/components/providers.tsx +6 -2
  46. package/templates/storefront-services/AGENTS.md +12 -0
  47. package/templates/storefront-services/app/layout.tsx +2 -0
  48. package/templates/storefront-services/components/cart-drawer.tsx +14 -0
  49. package/templates/storefront-services/components/cart-pill.tsx +11 -7
  50. package/templates/storefront-services/components/providers.tsx +6 -2
@@ -575,6 +575,10 @@ interface AuthService {
575
575
  }): Session;
576
576
  status(token: string | null): {
577
577
  is_authenticated: boolean;
578
+ account_id?: string;
579
+ user_type: "customer" | "guest";
580
+ session_expires_at?: number;
581
+ /** @deprecated Kept for SDK back-compat; production lens does not return this. */
578
582
  session?: Session;
579
583
  };
580
584
  }
@@ -736,6 +740,7 @@ interface CheckoutResult {
736
740
  payment_status: string;
737
741
  payment_reference?: string;
738
742
  requires_authorization: boolean;
743
+ authorization_type?: "otp" | "pin" | "phone" | "birthday" | "address";
739
744
  next_action: {
740
745
  type: "none";
741
746
  } | {
@@ -749,6 +754,12 @@ interface CheckoutResult {
749
754
  provider?: string;
750
755
  authorization_url?: string;
751
756
  display_text?: string;
757
+ /** Bearer token used for guest order access (lookup `/orders/{id}?token=...`). */
758
+ bill_token?: string;
759
+ /** Stripe-style client secret for direct PaymentIntent confirmation. */
760
+ client_secret?: string;
761
+ /** Stripe publishable key (mock value in dev). */
762
+ public_key?: string;
752
763
  }
753
764
 
754
765
  interface OrderService {
@@ -811,22 +822,33 @@ interface SchedulingService {
811
822
  interface InventoryService {
812
823
  productStock(productId: string): {
813
824
  product_id: string;
814
- quantity: number;
815
- available: boolean;
825
+ location_id?: string;
826
+ stock_level: number;
827
+ in_stock: boolean;
816
828
  };
817
829
  variantStock(variantId: string): {
818
830
  variant_id: string;
819
- quantity: number;
820
- available: boolean;
831
+ location_id?: string;
832
+ stock_level: number;
833
+ in_stock: boolean;
821
834
  };
822
- productAvailability(productId: string): {
835
+ productAvailability(productId: string, quantity?: number): {
823
836
  product_id: string;
837
+ quantity: number;
824
838
  is_available: boolean;
825
839
  };
826
- variantAvailability(variantId: string): {
840
+ variantAvailability(variantId: string, quantity?: number): {
827
841
  variant_id: string;
842
+ quantity: number;
828
843
  is_available: boolean;
829
844
  };
845
+ /**
846
+ * Reserve stock for a cart item. No-op for product types that don't track
847
+ * inventory (services, digital). Throws on insufficient stock.
848
+ */
849
+ reserve(productId: string, variantId: string | undefined, quantity: number): void;
850
+ /** Release a previously-reserved quantity (e.g. when a line is removed). */
851
+ release(productId: string, variantId: string | undefined, quantity: number): void;
830
852
  }
831
853
 
832
854
  interface FxService {
@@ -575,6 +575,10 @@ interface AuthService {
575
575
  }): Session;
576
576
  status(token: string | null): {
577
577
  is_authenticated: boolean;
578
+ account_id?: string;
579
+ user_type: "customer" | "guest";
580
+ session_expires_at?: number;
581
+ /** @deprecated Kept for SDK back-compat; production lens does not return this. */
578
582
  session?: Session;
579
583
  };
580
584
  }
@@ -736,6 +740,7 @@ interface CheckoutResult {
736
740
  payment_status: string;
737
741
  payment_reference?: string;
738
742
  requires_authorization: boolean;
743
+ authorization_type?: "otp" | "pin" | "phone" | "birthday" | "address";
739
744
  next_action: {
740
745
  type: "none";
741
746
  } | {
@@ -749,6 +754,12 @@ interface CheckoutResult {
749
754
  provider?: string;
750
755
  authorization_url?: string;
751
756
  display_text?: string;
757
+ /** Bearer token used for guest order access (lookup `/orders/{id}?token=...`). */
758
+ bill_token?: string;
759
+ /** Stripe-style client secret for direct PaymentIntent confirmation. */
760
+ client_secret?: string;
761
+ /** Stripe publishable key (mock value in dev). */
762
+ public_key?: string;
752
763
  }
753
764
 
754
765
  interface OrderService {
@@ -811,22 +822,33 @@ interface SchedulingService {
811
822
  interface InventoryService {
812
823
  productStock(productId: string): {
813
824
  product_id: string;
814
- quantity: number;
815
- available: boolean;
825
+ location_id?: string;
826
+ stock_level: number;
827
+ in_stock: boolean;
816
828
  };
817
829
  variantStock(variantId: string): {
818
830
  variant_id: string;
819
- quantity: number;
820
- available: boolean;
831
+ location_id?: string;
832
+ stock_level: number;
833
+ in_stock: boolean;
821
834
  };
822
- productAvailability(productId: string): {
835
+ productAvailability(productId: string, quantity?: number): {
823
836
  product_id: string;
837
+ quantity: number;
824
838
  is_available: boolean;
825
839
  };
826
- variantAvailability(variantId: string): {
840
+ variantAvailability(variantId: string, quantity?: number): {
827
841
  variant_id: string;
842
+ quantity: number;
828
843
  is_available: boolean;
829
844
  };
845
+ /**
846
+ * Reserve stock for a cart item. No-op for product types that don't track
847
+ * inventory (services, digital). Throws on insufficient stock.
848
+ */
849
+ reserve(productId: string, variantId: string | undefined, quantity: number): void;
850
+ /** Release a previously-reserved quantity (e.g. when a line is removed). */
851
+ release(productId: string, variantId: string | undefined, quantity: number): void;
830
852
  }
831
853
 
832
854
  interface FxService {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.44.11",
3
+ "version": "0.44.14",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "account",
3
+ "title": "CimplifyAccount",
4
+ "description": "Iframe wrapper for the Cimplify account portal — sign-in, orders, addresses, settings.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "account.tsx",
12
+ "content": "\"use client\";\n\nimport React, { useEffect, useRef, useState, useMemo } from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { useOptionalCimplify } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CimplifyAccountProps {\n /** CimplifyClient instance. Falls back to provider context. */\n client?: CimplifyClient;\n /** Override the Link base URL. */\n linkUrl?: string;\n /** Initial section to show: \"dashboard\" | \"orders\" | \"addresses\" | \"payment-methods\" | \"sessions\" | \"settings\". */\n section?: string;\n /** Appearance variables — passed to Link for theming. */\n appearance?: {\n theme?: \"light\" | \"dark\";\n variables?: {\n primaryColor?: string;\n fontFamily?: string;\n borderRadius?: string;\n };\n };\n /** Called when the user logs out from the account portal. */\n onLogout?: () => void;\n /** Additional CSS class for the container. */\n className?: string;\n}\n\nconst DEFAULT_LINK_URL = \"https://link.cimplify.io\";\n\nexport function CimplifyAccount({\n client: clientProp,\n linkUrl,\n section,\n appearance,\n onLogout,\n className,\n}: CimplifyAccountProps): React.ReactElement {\n const context = useOptionalCimplify();\n const client = clientProp ?? context?.client;\n const resolvedLinkUrl = linkUrl || DEFAULT_LINK_URL;\n\n const iframeRef = useRef<HTMLIFrameElement | null>(null);\n const [height, setHeight] = useState(400);\n const [isReady, setIsReady] = useState(false);\n\n const iframeSrc = useMemo(() => {\n const path = section ? `/elements/account/${section}` : \"/elements/account\";\n const url = new URL(path, resolvedLinkUrl);\n if (client) {\n const businessId = client.getBusinessId?.() ?? \"\";\n if (businessId) url.searchParams.set(\"businessId\", businessId);\n }\n return url.toString();\n }, [resolvedLinkUrl, section, client]);\n\n // Listen for messages from the iframe\n useEffect(() => {\n function handleMessage(event: MessageEvent) {\n if (!event.data || typeof event.data !== \"object\") return;\n\n // Only accept messages from our iframe\n if (iframeRef.current?.contentWindow && event.source !== iframeRef.current.contentWindow) {\n return;\n }\n\n switch (event.data.type) {\n case \"ready\":\n setIsReady(true);\n if (typeof event.data.height === \"number\") {\n setHeight(event.data.height);\n }\n // Send init with token + appearance\n sendInit();\n break;\n\n case \"height_change\":\n if (typeof event.data.height === \"number\") {\n setHeight(event.data.height);\n }\n break;\n\n case \"logout_complete\":\n onLogout?.();\n break;\n }\n }\n\n window.addEventListener(\"message\", handleMessage);\n return () => window.removeEventListener(\"message\", handleMessage);\n }, [appearance, client, onLogout]);\n\n function sendInit() {\n const contentWindow = iframeRef.current?.contentWindow;\n if (!contentWindow) return;\n\n const token = client?.getAccessToken?.();\n contentWindow.postMessage(\n {\n type: \"init\",\n token: token ?? undefined,\n appearance: appearance ?? undefined,\n },\n resolvedLinkUrl,\n );\n }\n\n return (\n <div\n data-cimplify-account\n className={cn(\"relative overflow-hidden\", className)}\n >\n {!isReady && (\n <div className=\"flex items-center justify-center py-16\">\n <div className=\"w-6 h-6 border-2 border-border border-t-foreground rounded-full animate-spin\" />\n </div>\n )}\n <iframe\n ref={iframeRef}\n src={iframeSrc}\n style={{\n border: \"none\",\n width: \"100%\",\n height: `${height}px`,\n display: isReady ? \"block\" : \"none\",\n overflow: \"hidden\",\n background: \"transparent\",\n }}\n allow=\"geolocation\"\n sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"\n />\n </div>\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "billing-plan-selector",
3
+ "title": "BillingPlanSelector",
4
+ "description": "Subscription / billing-plan picker — surfaces eligible plans with pricing and trial periods.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "price",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "billing-plan-selector.tsx",
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductBillingPlan, BillingFrequency } 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 /** 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 — 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 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} />\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} />\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"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "cart-drawer",
3
+ "title": "CartDrawer",
4
+ "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cart-summary",
8
+ "price",
9
+ "cn"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "cart-drawer.tsx",
14
+ "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 → >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 — 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\">✓ 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 — <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"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "currency-selector",
3
+ "title": "CurrencySelector",
4
+ "description": "Multi-currency switcher backed by the FX provider — locks display currency and quote ID.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "currency-selector.tsx",
12
+ "content": "\"use client\";\n\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport type { CimplifyError } from \"@cimplify/sdk\";\nimport type { FxRateResponse } from \"../types/fx\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CurrencySelectorClassNames {\n root?: string;\n option?: string;\n activeOption?: string;\n rate?: string;\n label?: string;\n loading?: string;\n}\n\nexport interface CurrencySelectorProps {\n /** Available currencies to display. */\n currencies: CurrencyCode[];\n /** Currently selected currency. */\n currentCurrency?: CurrencyCode;\n /** Called when a currency is selected. */\n onCurrencyChange?: (currency: CurrencyCode) => void;\n /** Show the exchange rate next to each currency. Requires `baseCurrency`. */\n showRate?: boolean;\n /** Base currency used for rate display. */\n baseCurrency?: CurrencyCode;\n /** Custom currency option renderer. */\n renderCurrency?: (\n currency: CurrencyCode,\n isActive: boolean,\n rate: FxRateResponse | null,\n ) => React.ReactNode;\n className?: string;\n classNames?: CurrencySelectorClassNames;\n}\n\n/**\n * CurrencySelector — currency switcher for international customers.\n *\n * Renders selectable currency options with optional exchange rate display.\n * Returns `null` when there is only one currency (no selector needed).\n */\nexport function CurrencySelector({\n currencies,\n currentCurrency,\n onCurrencyChange,\n showRate = false,\n baseCurrency,\n renderCurrency,\n className,\n classNames,\n}: CurrencySelectorProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [rates, setRates] = useState<Record<string, FxRateResponse>>({});\n const [isLoadingRates, setIsLoadingRates] = useState(false);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!showRate || !baseCurrency || !client) return;\n\n const targets = currencies.filter((c) => c !== baseCurrency);\n if (targets.length === 0) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoadingRates(true);\n\n void (async () => {\n const fetched: Record<string, FxRateResponse> = {};\n\n await Promise.all(\n targets.map(async (currency) => {\n const res = await client.fx.getRate(baseCurrency, currency);\n if (res.ok) {\n fetched[currency] = res.value;\n }\n }),\n );\n\n if (nextRequestId !== requestIdRef.current) return;\n\n setRates(fetched);\n setIsLoadingRates(false);\n })();\n }, [client, showRate, baseCurrency, currencies]);\n\n if (currencies.length <= 1) {\n return null;\n }\n\n return (\n <div data-cimplify-currency-selector className={cn(className, classNames?.root)}>\n {currencies.map((currency) => {\n const isActive = currentCurrency === currency;\n const rate = rates[currency] ?? null;\n\n return (\n <button\n key={currency}\n type=\"button\"\n onClick={() => onCurrencyChange?.(currency)}\n data-cimplify-currency-option\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(\n classNames?.option,\n isActive && classNames?.activeOption,\n )}\n >\n {renderCurrency ? (\n renderCurrency(currency, isActive, rate)\n ) : (\n <>\n <span data-cimplify-currency-label className={classNames?.label}>\n {currency}\n </span>\n {showRate && baseCurrency && currency !== baseCurrency && (\n <span data-cimplify-currency-rate className={classNames?.rate}>\n {isLoadingRates\n ? \"...\"\n : rate\n ? `1 ${baseCurrency} = ${rate.rate.toFixed(4)} ${currency}`\n : null}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "customer-input-fields",
3
+ "title": "CustomerInputFields",
4
+ "description": "Per-product custom input fields — text, number, date, time, file upload, image upload, single/multi-select.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "customer-input-fields.tsx",
12
+ "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 { Price } from \"@cimplify/sdk/react\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\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 className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\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 classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\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 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!} />\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 <input\n type=\"date\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={inputClass}\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 dtLocalValue = typeof value === \"string\" && value.includes(\"T\")\n ? value.slice(0, 16)\n : typeof value === \"string\" ? value : \"\";\n return (\n <input\n type=\"datetime-local\"\n value={dtLocalValue}\n onChange={(e) => {\n if (!e.target.value) { onValueChange(undefined); return; }\n const date = new Date(e.target.value);\n onValueChange(Number.isNaN(date.getTime()) ? e.target.value : date.toISOString());\n }}\n required={field.is_required}\n className={inputClass}\n />\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <input\n type=\"time\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={inputClass}\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 inputClass={inputClass}\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 && ` · 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 inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\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-2 gap-3\", classNames?.dateRangeInput)}>\n <div>\n <label className=\"text-xs text-muted-foreground mb-1 block\">Start</label>\n <input\n type=\"date\"\n value={range.start || \"\"}\n onChange={(e) => update(\"start\", e.target.value)}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n <div>\n <label className=\"text-xs text-muted-foreground mb-1 block\">End</label>\n <input\n type=\"date\"\n value={range.end || \"\"}\n onChange={(e) => update(\"end\", e.target.value)}\n required={field.is_required}\n className={inputClass}\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"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "delivery-estimate",
3
+ "title": "DeliveryEstimate",
4
+ "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "price",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "delivery-estimate.tsx",
13
+ "content": "\"use client\";\n\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { DeliveryFeeResponse } from \"../delivery\";\nimport type { CimplifyError } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DeliveryEstimateClassNames {\n root?: string;\n fee?: string;\n currency?: string;\n notServiceable?: string;\n loading?: string;\n error?: string;\n}\n\nexport interface DeliveryEstimateProps {\n /** Drop-off latitude. */\n latitude: number;\n /** Drop-off longitude. */\n longitude: number;\n /** Country code for regional fee lookup. */\n country?: string;\n /** Called when the delivery fee is successfully calculated. */\n onFeeCalculated?: (fee: DeliveryFeeResponse) => void;\n className?: string;\n classNames?: DeliveryEstimateClassNames;\n}\n\n/**\n * DeliveryEstimate — displays a pre-checkout delivery fee estimate.\n *\n * Fetches the delivery fee from `client.delivery.getFee` when coordinates change.\n * Shows fee amount with currency, a \"not serviceable\" message, or loading state.\n */\nexport function DeliveryEstimate({\n latitude,\n longitude,\n country,\n onFeeCalculated,\n className,\n classNames,\n}: DeliveryEstimateProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [result, setResult] = useState<DeliveryFeeResponse | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<CimplifyError | null>(null);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!client) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoading(true);\n setError(null);\n\n void (async () => {\n const res = await client.delivery.getFee(latitude, longitude, country);\n\n if (nextRequestId !== requestIdRef.current) return;\n\n if (!res.ok) {\n setError(res.error);\n setResult(null);\n setIsLoading(false);\n return;\n }\n\n setResult(res.value);\n setError(null);\n setIsLoading(false);\n onFeeCalculated?.(res.value);\n })();\n }, [client, latitude, longitude, country]); // eslint-disable-line react-hooks/exhaustive-deps -- onFeeCalculated is a callback prop, not a reactive dependency\n\n if (!client) {\n return null;\n }\n\n if (isLoading) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (error) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n className={cn(className, classNames?.root, classNames?.error)}\n >\n <span>{error.message}</span>\n </div>\n );\n }\n\n if (!result || !result.serviceable) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n className={cn(className, classNames?.root, classNames?.notServiceable)}\n >\n <span>Not serviceable</span>\n </div>\n );\n }\n\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"true\"\n className={cn(className, classNames?.root)}\n >\n {result.fee != null && (\n <span data-cimplify-delivery-fee className={classNames?.fee}>\n <Price amount={result.fee} />\n </span>\n )}\n {result.currency && (\n <span data-cimplify-delivery-currency className={classNames?.currency}>\n {result.currency}\n </span>\n )}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -568,6 +568,111 @@
568
568
  "registryDependencies": [
569
569
  "cn"
570
570
  ]
571
+ },
572
+ {
573
+ "name": "cart-drawer",
574
+ "title": "CartDrawer",
575
+ "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.",
576
+ "type": "component",
577
+ "registryDependencies": [
578
+ "cart-summary",
579
+ "price",
580
+ "cn"
581
+ ]
582
+ },
583
+ {
584
+ "name": "account",
585
+ "title": "CimplifyAccount",
586
+ "description": "Iframe wrapper for the Cimplify account portal — sign-in, orders, addresses, settings.",
587
+ "type": "component",
588
+ "registryDependencies": [
589
+ "cn"
590
+ ]
591
+ },
592
+ {
593
+ "name": "billing-plan-selector",
594
+ "title": "BillingPlanSelector",
595
+ "description": "Subscription / billing-plan picker — surfaces eligible plans with pricing and trial periods.",
596
+ "type": "component",
597
+ "registryDependencies": [
598
+ "price",
599
+ "cn"
600
+ ]
601
+ },
602
+ {
603
+ "name": "currency-selector",
604
+ "title": "CurrencySelector",
605
+ "description": "Multi-currency switcher backed by the FX provider — locks display currency and quote ID.",
606
+ "type": "component",
607
+ "registryDependencies": [
608
+ "cn"
609
+ ]
610
+ },
611
+ {
612
+ "name": "customer-input-fields",
613
+ "title": "CustomerInputFields",
614
+ "description": "Per-product custom input fields — text, number, date, time, file upload, image upload, single/multi-select.",
615
+ "type": "component",
616
+ "registryDependencies": [
617
+ "cn"
618
+ ]
619
+ },
620
+ {
621
+ "name": "delivery-estimate",
622
+ "title": "DeliveryEstimate",
623
+ "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.",
624
+ "type": "component",
625
+ "registryDependencies": [
626
+ "price",
627
+ "cn"
628
+ ]
629
+ },
630
+ {
631
+ "name": "location-picker",
632
+ "title": "LocationPicker",
633
+ "description": "Branch / pickup-point selector for businesses with multiple locations.",
634
+ "type": "component",
635
+ "registryDependencies": [
636
+ "cn"
637
+ ]
638
+ },
639
+ {
640
+ "name": "resource-picker",
641
+ "title": "ResourcePicker",
642
+ "description": "Staff / room / resource picker for bookable services — used by services and restaurant reservation flows.",
643
+ "type": "component",
644
+ "registryDependencies": [
645
+ "cn"
646
+ ]
647
+ },
648
+ {
649
+ "name": "recently-viewed",
650
+ "title": "RecentlyViewed",
651
+ "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.",
652
+ "type": "component",
653
+ "registryDependencies": [
654
+ "product-card",
655
+ "cn"
656
+ ]
657
+ },
658
+ {
659
+ "name": "recommendation-carousel",
660
+ "title": "RecommendationCarousel",
661
+ "description": "Personalized product carousel powered by /activity/recommendations — frequently bought, related, trending.",
662
+ "type": "component",
663
+ "registryDependencies": [
664
+ "product-card",
665
+ "cn"
666
+ ]
667
+ },
668
+ {
669
+ "name": "session-message-banner",
670
+ "title": "SessionMessageBanner",
671
+ "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.",
672
+ "type": "component",
673
+ "registryDependencies": [
674
+ "cn"
675
+ ]
571
676
  }
572
677
  ]
573
678
  }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "location-picker",
3
+ "title": "LocationPicker",
4
+ "description": "Branch / pickup-point selector for businesses with multiple locations.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "location-picker.tsx",
12
+ "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 — 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"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "recently-viewed",
3
+ "title": "RecentlyViewed",
4
+ "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "product-card",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "recently-viewed.tsx",
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport { useActivityState } from \"./hooks/use-activity-state\";\nimport { cn } from \"@cimplify/sdk/react\";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed — displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type=\"button\"\n onClick={() => onProductClick?.(product)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "recommendation-carousel",
3
+ "title": "RecommendationCarousel",
4
+ "description": "Personalized product carousel powered by /activity/recommendations — frequently bought, related, trending.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "product-card",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "recommendation-carousel.tsx",
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { ActivityRecommendation } from \"../activity\";\nimport { useRecommendations } from \"./hooks/use-recommendations\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel — displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", overflowX: \"auto\", gap: \"1rem\" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type=\"button\"\n onClick={() => onProductClick?.(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? \"Product\"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "resource-picker",
3
+ "title": "ResourcePicker",
4
+ "description": "Staff / room / resource picker for bookable services — used by services and restaurant reservation flows.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "resource-picker.tsx",
12
+ "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 { Room } from \"../types/business\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst ANY_VALUE = \"__any__\";\n\nexport interface Resource {\n id: string;\n name: string;\n description?: string;\n capacity?: number;\n floor?: string;\n image_url?: string;\n is_available?: boolean;\n}\n\nexport interface ResourcePickerClassNames {\n root?: string;\n option?: string;\n image?: string;\n name?: string;\n description?: string;\n meta?: string;\n capacity?: string;\n floor?: string;\n unavailable?: string;\n}\n\nexport interface ResourcePickerProps {\n /** List of available resources (rooms, equipment, etc.). */\n resources: Resource[];\n /** Currently selected resource ID, or null for \"Any available\". */\n selectedResourceId?: string | null;\n /** Called when a resource is selected. Passes null for \"Any available\". */\n onResourceSelect?: (resourceId: string | null) => void;\n /** Show \"Any available\" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the \"Any available\" option. */\n anyLabel?: string;\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?: ResourcePickerClassNames;\n}\n\nexport function roomToResource(room: Room): Resource {\n return {\n id: room.id,\n name: room.name,\n capacity: room.capacity,\n floor: room.floor,\n is_available: room.status === \"available\",\n };\n}\n\nexport function ResourcePicker({\n resources,\n selectedResourceId,\n onResourceSelect,\n showAnyOption = true,\n anyLabel = \"Any available\",\n renderImage,\n className,\n classNames,\n}: ResourcePickerProps): React.ReactElement {\n const groupValue =\n selectedResourceId === null ? ANY_VALUE : (selectedResourceId ?? \"\");\n\n return (\n <RadioGroup\n data-cimplify-resource-picker\n className={cn(\"flex flex-col gap-2\", className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onResourceSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-resource-option\n data-selected={selectedResourceId === null || undefined}\n data-any\n className={cn(\n \"flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer\",\n \"hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5\",\n classNames?.option,\n )}\n >\n <span data-cimplify-resource-name className={cn(\"font-medium text-foreground\", classNames?.name)}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {resources.map((resource) => {\n const unavailable = resource.is_available === false;\n return (\n <Radio.Root\n key={resource.id}\n value={resource.id}\n disabled={unavailable}\n data-cimplify-resource-option\n data-selected={selectedResourceId === resource.id || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n \"flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer\",\n \"hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5\",\n unavailable && \"opacity-50 cursor-not-allowed\",\n classNames?.option,\n unavailable ? classNames?.unavailable : undefined,\n )}\n >\n {resource.image_url && (\n renderImage ? (\n renderImage({\n src: resource.image_url,\n alt: resource.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={resource.image_url}\n alt={resource.name}\n data-cimplify-resource-image\n className={cn(\"w-10 h-10 rounded-lg object-cover\", classNames?.image)}\n />\n )\n )}\n <div className=\"flex flex-col gap-0.5 flex-1 min-w-0\">\n <span data-cimplify-resource-name className={cn(\"font-medium text-foreground\", classNames?.name)}>\n {resource.name}\n </span>\n {resource.description && (\n <span data-cimplify-resource-description className={cn(\"text-sm text-muted-foreground truncate\", classNames?.description)}>\n {resource.description}\n </span>\n )}\n <div data-cimplify-resource-meta className={cn(\"flex items-center gap-2 text-xs text-muted-foreground\", classNames?.meta)}>\n {resource.capacity !== undefined && (\n <span data-cimplify-resource-capacity className={classNames?.capacity}>\n Up to {resource.capacity}\n </span>\n )}\n {resource.floor && (\n <span data-cimplify-resource-floor className={classNames?.floor}>\n {resource.floor}\n </span>\n )}\n </div>\n </div>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n );\n}\n"
13
+ }
14
+ ]
15
+ }