@cimplify/sdk 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/advanced.d.mts +2 -2
  2. package/dist/advanced.d.ts +2 -2
  3. package/dist/advanced.js +90 -2906
  4. package/dist/advanced.mjs +3 -2886
  5. package/dist/chunk-FLPI2VVJ.js +72 -0
  6. package/dist/chunk-G6KNFYJD.mjs +687 -0
  7. package/dist/chunk-GFKBH7R2.mjs +2759 -0
  8. package/dist/chunk-J65U5SED.js +714 -0
  9. package/dist/chunk-LM3CJ4L7.mjs +69 -0
  10. package/dist/chunk-OITG2SEN.js +692 -0
  11. package/dist/chunk-OVM5YWM5.mjs +674 -0
  12. package/dist/chunk-REPTMTQ4.js +2810 -0
  13. package/dist/chunk-S6TU55KP.js +170 -0
  14. package/dist/chunk-WDIMISRC.mjs +166 -0
  15. package/dist/{client-DTTtSSmQ.d.ts → client-BaolRdB4.d.mts} +3 -3
  16. package/dist/{client-Cb7r8u6t.d.mts → client-C59R79gx.d.ts} +3 -3
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.d.ts +4 -4
  19. package/dist/index.js +401 -4412
  20. package/dist/index.mjs +5 -4317
  21. package/dist/{payment-Dvd7nRmk.d.mts → payment-C6ULw3K-.d.mts} +1 -1
  22. package/dist/{payment-Dvd7nRmk.d.ts → payment-C6ULw3K-.d.ts} +1 -1
  23. package/dist/{price-DrfhwHpw.d.ts → price-Bd6JBV8W.d.ts} +1 -1
  24. package/dist/{price-BDZNXiLS.d.mts → price-CbR6DCt0.d.mts} +1 -1
  25. package/dist/react.d.mts +12 -7
  26. package/dist/react.d.ts +12 -7
  27. package/dist/react.js +377 -3961
  28. package/dist/react.mjs +326 -3913
  29. package/dist/utils.d.mts +2 -2
  30. package/dist/utils.d.ts +2 -2
  31. package/dist/utils.js +122 -720
  32. package/dist/utils.mjs +2 -694
  33. package/package.json +1 -1
  34. package/registry/add-on-selector.json +1 -1
  35. package/registry/checkout-page.json +1 -1
  36. package/registry/composite-selector.json +1 -1
  37. package/registry/product-grid.json +1 -1
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "add-on-selector.tsx",
12
- "content": "\"use client\";\n\nimport React, { useCallback } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport type { AddOnWithOptions } 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 className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const isOptionSelected = useCallback(\n (optionId: string) => selectedOptions.includes(optionId),\n [selectedOptions],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedOptions.includes(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 const currentCount = selectedOptions.filter((id) =>\n addOn.options.some((o) => o.id === id),\n ).length;\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, 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 const currentSelections = selectedOptions.filter((id) =>\n addOn.options.some((o) => o.id === id),\n ).length;\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} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
12
+ "content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport type { AddOnWithOptions } 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 className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\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} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
13
13
  }
14
14
  ]
15
15
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "checkout-page.tsx",
12
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProcessCheckoutResult } from \"@cimplify/sdk\";\nimport { useCimplify } 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 — 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 } = useCimplify();\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"
12
+ "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 — 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"
13
13
  }
14
14
  ]
15
15
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "composite-selector.tsx",
12
- "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 { useCimplify } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } 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 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 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 onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplify();\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 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 {[...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .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.components\n .filter((c) => c.is_available && !c.is_archived)\n .sort((a, b) => a.display_order - b.display_order)\n .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.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 &#x2212;\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} />\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} />\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} />\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} 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"
12
+ "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 { 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 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 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 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.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 &#x2212;\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} />\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} />\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} />\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} 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"
13
13
  }
14
14
  ]
15
15
  }
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "product-grid.tsx",
13
- "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No products found\"}</p>\n </div>\n );\n }\n\n const css = [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(\"\");\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n"
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(\"\"),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No products found\"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n"
14
14
  }
15
15
  ]
16
16
  }