{
  "name": "composite-selector",
  "title": "CompositeSelector",
  "description": "Composite product builder with group constraints and live pricing.",
  "type": "component",
  "registryDependencies": [
    "price"
  ],
  "files": [
    {
      "path": "composite-selector.tsx",
      "content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport type {\n  CompositeGroupView,\n  CompositeComponentView,\n  ComponentSelectionInput,\n  CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { useCimplifyClient } from \"@cimplify/sdk/react\";\n\nexport interface CompositeSelectorClassNames {\n  root?: string;\n  group?: string;\n  groupHeader?: string;\n  groupName?: string;\n  required?: string;\n  groupDescription?: string;\n  groupConstraint?: string;\n  validation?: string;\n  components?: string;\n  component?: string;\n  componentSelected?: string;\n  componentInfo?: string;\n  componentName?: string;\n  typeBadge?: string;\n  serviceBadge?: string;\n  digitalBadge?: string;\n  badgePopular?: string;\n  badgePremium?: string;\n  componentDescription?: string;\n  componentCalories?: string;\n  qty?: string;\n  qtyButton?: string;\n  qtyValue?: string;\n  summary?: string;\n  summaryLine?: string;\n  summaryTotal?: string;\n  calculating?: string;\n  priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n  compositeId: string;\n  groups: CompositeGroupView[];\n  currency?: CurrencyCode;\n  onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n  onPriceChange?: (price: CompositePriceResult | null) => void;\n  onReady?: (ready: boolean) => void;\n  skipPriceFetch?: boolean;\n  className?: string;\n  classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n  compositeId,\n  groups,\n  currency,\n  onSelectionsChange,\n  onPriceChange,\n  onReady,\n  skipPriceFetch,\n  className,\n  classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n  const { client } = useCimplifyClient();\n\n  const [groupSelections, setGroupSelections] = useState<\n    Record<string, Record<string, number>>\n  >({});\n  const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n  const [isPriceLoading, setIsPriceLoading] = useState(false);\n  const [priceError, setPriceError] = useState(false);\n\n  const selections = useMemo((): ComponentSelectionInput[] => {\n    const result: ComponentSelectionInput[] = [];\n    for (const groupSels of Object.values(groupSelections)) {\n      for (const [componentId, qty] of Object.entries(groupSels)) {\n        if (qty > 0) {\n          result.push({ component_id: componentId, quantity: qty });\n        }\n      }\n    }\n    return result;\n  }, [groupSelections]);\n\n  useEffect(() => {\n    onSelectionsChange(selections);\n  }, [selections, onSelectionsChange]);\n\n  useEffect(() => {\n    onPriceChange?.(priceResult);\n  }, [priceResult, onPriceChange]);\n\n  const allGroupsSatisfied = useMemo(() => {\n    for (const group of groups) {\n      const groupSels = groupSelections[group.id] || {};\n      const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n      if (totalSelected < group.min_selections) return false;\n    }\n    return true;\n  }, [groups, groupSelections]);\n\n  const sortedGroups = useMemo(\n    () =>\n      [...groups]\n        .sort((a, b) => a.display_order - b.display_order)\n        .map((group) => ({\n          ...group,\n          _sortedComponents: group.components\n            .filter((component) => component.is_available && !component.is_archived)\n            .sort((a, b) => a.display_order - b.display_order),\n        })),\n    [groups],\n  );\n\n  useEffect(() => {\n    onReady?.(allGroupsSatisfied);\n  }, [allGroupsSatisfied, onReady]);\n\n  useEffect(() => {\n    if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n    let cancelled = false;\n    const timer = setTimeout(() => {\n      void (async () => {\n        setIsPriceLoading(true);\n        setPriceError(false);\n        try {\n          const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n          if (cancelled) return;\n          if (result.ok) {\n            setPriceResult(result.value);\n          } else {\n            setPriceError(true);\n          }\n        } catch {\n          if (!cancelled) setPriceError(true);\n        } finally {\n          if (!cancelled) setIsPriceLoading(false);\n        }\n      })();\n    }, 300);\n\n    return () => {\n      cancelled = true;\n      clearTimeout(timer);\n    };\n  }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n  const toggleComponent = useCallback(\n    (group: CompositeGroupView, component: CompositeComponentView) => {\n      setGroupSelections((prev) => {\n        const groupSels = { ...(prev[group.id] || {}) };\n        const currentQty = groupSels[component.id] || 0;\n\n        if (currentQty > 0) {\n          if (group.min_selections > 0) {\n            const totalOthers = Object.entries(groupSels)\n              .filter(([id]) => id !== component.id)\n              .reduce((sum, [, q]) => sum + q, 0);\n            if (totalOthers < group.min_selections) {\n              return prev;\n            }\n          }\n          delete groupSels[component.id];\n        } else {\n          const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n          if (group.max_selections && totalSelected >= group.max_selections) {\n            if (group.max_selections === 1) {\n              return { ...prev, [group.id]: { [component.id]: 1 } };\n            }\n            return prev;\n          }\n          groupSels[component.id] = 1;\n        }\n\n        return { ...prev, [group.id]: groupSels };\n      });\n    },\n    [],\n  );\n\n  const updateQuantity = useCallback(\n    (group: CompositeGroupView, componentId: string, newValue: number) => {\n      setGroupSelections((prev) => {\n        const groupSels = { ...(prev[group.id] || {}) };\n        const current = groupSels[componentId] || 0;\n        const next = Math.max(0, newValue);\n\n        if (next === current) return prev;\n\n        const delta = next - current;\n\n        if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n          return prev;\n        }\n\n        const totalAfter = Object.entries(groupSels)\n          .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n        if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n          return prev;\n        }\n\n        if (delta < 0 && totalAfter < group.min_selections) {\n          return prev;\n        }\n\n        if (next === 0) {\n          delete groupSels[componentId];\n        } else {\n          groupSels[componentId] = next;\n        }\n\n        return { ...prev, [group.id]: groupSels };\n      });\n    },\n    [],\n  );\n\n  if (groups.length === 0) {\n    return null;\n  }\n\n  return (\n    <div data-cimplify-composite-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n      {sortedGroups.map((group) => {\n          const groupSels = groupSelections[group.id] || {};\n          const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n          const minMet = totalSelected >= group.min_selections;\n          const isSingleSelect = group.max_selections === 1;\n\n          return (\n            <div\n              key={group.id}\n              data-cimplify-composite-group\n              className={cn(classNames?.group)}\n            >\n              <div\n                data-cimplify-composite-group-header\n                className={cn(\"flex items-center justify-between py-3\", classNames?.groupHeader)}\n              >\n                <div>\n                  <span\n                    data-cimplify-composite-group-name\n                    className={cn(\"text-base font-bold\", classNames?.groupName)}\n                  >\n                    {group.name}\n                  </span>\n                  {group.description && (\n                    <span\n                      data-cimplify-composite-group-description\n                      className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupDescription)}\n                    >\n                      {group.description}\n                    </span>\n                  )}\n                  <span\n                    data-cimplify-composite-group-constraint\n                    className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupConstraint)}\n                  >\n                    {group.min_selections > 0 && group.max_selections\n                      ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n                      : group.min_selections > 0\n                        ? `Choose at least ${group.min_selections}`\n                        : group.max_selections\n                          ? `Choose up to ${group.max_selections}`\n                          : \"Choose as many as you like\"}\n                  </span>\n                </div>\n                {group.min_selections > 0 && (\n                  <span\n                    data-cimplify-composite-required\n                    className={cn(\n                      \"text-xs font-semibold px-2.5 py-1 rounded shrink-0\",\n                      !minMet\n                        ? \"text-destructive bg-destructive/10\"\n                        : \"text-destructive bg-destructive/10\",\n                      classNames?.required,\n                    )}\n                  >\n                    Required\n                  </span>\n                )}\n              </div>\n\n              <div\n                data-cimplify-composite-components\n                role={isSingleSelect ? \"radiogroup\" : \"group\"}\n                aria-label={group.name}\n                className={cn(\"divide-y divide-border\", classNames?.components)}\n              >\n                {group._sortedComponents.map((component) => {\n                    const qty = groupSels[component.id] || 0;\n                    const isSelected = qty > 0;\n                    const displayName = component.display_name || component.id;\n\n                    return (\n                      <Checkbox.Root\n                        key={component.id}\n                        checked={isSelected}\n                        onCheckedChange={() => toggleComponent(group, component)}\n                        value={component.id}\n                        data-cimplify-composite-component\n                        data-selected={isSelected || undefined}\n                        className={cn(\n                          \"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer\",\n                          isSelected ? classNames?.componentSelected : classNames?.component,\n                        )}\n                      >\n                        <Checkbox.Indicator\n                          className=\"hidden\"\n                          keepMounted={false}\n                        />\n\n                        {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n                        {isSingleSelect ? (\n                          <span\n                            data-cimplify-composite-radio\n                            className={cn(\n                              \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n                              isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n                            )}\n                          >\n                            {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n                          </span>\n                        ) : (\n                          <span\n                            data-cimplify-composite-checkbox\n                            className={cn(\n                              \"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors\",\n                              isSelected ? \"border-primary bg-primary\" : \"border-muted-foreground/30\",\n                            )}\n                          >\n                            {isSelected && (\n                              <svg viewBox=\"0 0 12 12\" className=\"w-3 h-3 text-primary-foreground\" fill=\"none\">\n                                <path d=\"M2 6l3 3 5-5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n                              </svg>\n                            )}\n                          </span>\n                        )}\n\n                        <div\n                          data-cimplify-composite-component-info\n                          className={cn(\"flex-1 min-w-0\", classNames?.componentInfo)}\n                        >\n                          <span\n                            data-cimplify-composite-component-name\n                            className={cn(\"text-sm\", classNames?.componentName)}\n                          >\n                            {displayName}\n                          </span>\n                          {component.product_type === \"service\" && (\n                            <span\n                              data-cimplify-composite-type-badge=\"service\"\n                              className={cn(\n                                \"text-[10px] uppercase tracking-wider font-medium text-blue-600\",\n                                classNames?.typeBadge,\n                                classNames?.serviceBadge,\n                              )}\n                            >\n                              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"inline-block w-3 h-3 mr-0.5 -mt-px\" aria-hidden=\"true\">\n                                <circle cx=\"8\" cy=\"8\" r=\"7\" stroke=\"currentColor\" strokeWidth=\"1.5\"/>\n                                <path d=\"M8 4v4l2.5 1.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n                              </svg>\n                              Service\n                            </span>\n                          )}\n                          {component.product_type === \"digital\" && (\n                            <span\n                              data-cimplify-composite-type-badge=\"digital\"\n                              className={cn(\n                                \"text-[10px] uppercase tracking-wider font-medium text-violet-600\",\n                                classNames?.typeBadge,\n                                classNames?.digitalBadge,\n                              )}\n                            >\n                              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"inline-block w-3 h-3 mr-0.5 -mt-px\" aria-hidden=\"true\">\n                                <path d=\"M9 2L4 9h4l-1 5 5-7H8l1-5z\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n                              </svg>\n                              Digital\n                            </span>\n                          )}\n                          {component.is_popular && (\n                            <span\n                              data-cimplify-composite-badge=\"popular\"\n                              className={cn(\"text-[10px] uppercase tracking-wider text-primary font-medium\", classNames?.badgePopular)}\n                            >\n                              Popular\n                            </span>\n                          )}\n                          {component.is_premium && (\n                            <span\n                              data-cimplify-composite-badge=\"premium\"\n                              className={cn(\"text-[10px] uppercase tracking-wider text-amber-600 font-medium\", classNames?.badgePremium)}\n                            >\n                              Premium\n                            </span>\n                          )}\n                          {component.display_description && (\n                            <span\n                              data-cimplify-composite-component-description\n                              className={cn(\"block text-xs text-muted-foreground truncate\", classNames?.componentDescription)}\n                            >\n                              {component.display_description}\n                            </span>\n                          )}\n                          {component.calories != null && (\n                            <span\n                              data-cimplify-composite-component-calories\n                              className={cn(\"block text-xs text-muted-foreground/60\", classNames?.componentCalories)}\n                            >\n                              {component.calories} cal\n                            </span>\n                          )}\n                        </div>\n\n                        {group.allow_quantity && isSelected && (\n                          <NumberField.Root\n                            value={qty}\n                            onValueChange={(val) => {\n                              if (val != null) {\n                                updateQuantity(group, component.id, val);\n                              }\n                            }}\n                            min={0}\n                            max={group.max_quantity_per_component || undefined}\n                            step={1}\n                          >\n                            <NumberField.Group\n                              data-cimplify-composite-qty\n                              onClick={(e: React.MouseEvent) => e.stopPropagation()}\n                              className={cn(\"flex items-center gap-2\", classNames?.qty)}\n                            >\n                              <NumberField.Decrement\n                                aria-label={`Decrease ${displayName} quantity`}\n                                className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n                              >\n                                &#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} currency={currency} />\n                          </span>\n                        )}\n                      </Checkbox.Root>\n                    );\n                  })}\n              </div>\n            </div>\n          );\n        })}\n\n      {priceResult && (\n        <div\n          data-cimplify-composite-summary\n          className={cn(\"border-t border-border pt-4 space-y-1 text-sm\", classNames?.summary)}\n        >\n          {parsePrice(priceResult.base_price) !== 0 && (\n            <div\n              data-cimplify-composite-summary-line\n              className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n            >\n              <span>Base</span>\n              <Price amount={priceResult.base_price} currency={currency} />\n            </div>\n          )}\n          {parsePrice(priceResult.components_total) !== 0 && (\n            <div\n              data-cimplify-composite-summary-line\n              className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n            >\n              <span>Selections</span>\n              <Price amount={priceResult.components_total} currency={currency} />\n            </div>\n          )}\n          <div\n            data-cimplify-composite-summary-total\n            className={cn(\"flex justify-between font-medium pt-1 border-t border-border\", classNames?.summaryTotal)}\n          >\n            <span>Total</span>\n            <Price amount={priceResult.final_price} currency={currency} className=\"text-primary\" />\n          </div>\n        </div>\n      )}\n\n      {isPriceLoading && (\n        <div\n          data-cimplify-composite-calculating\n          className={cn(\"flex items-center gap-2 text-sm text-muted-foreground\", classNames?.calculating)}\n        >\n          Calculating price...\n        </div>\n      )}\n\n      {priceError && !isPriceLoading && (\n        <div\n          data-cimplify-composite-price-error\n          className={cn(\"text-sm text-destructive\", classNames?.priceError)}\n        >\n          Unable to calculate price\n        </div>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}
