{
  "name": "bundle-selector",
  "title": "BundleSelector",
  "description": "Bundle component picker with variant choices and price summary.",
  "type": "component",
  "registryDependencies": [
    "price"
  ],
  "files": [
    {
      "path": "bundle-selector.tsx",
      "content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType, DurationUnit } from \"@cimplify/sdk\";\nimport type { Money, CurrencyCode } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface BundleSelectorClassNames {\n  root?: string;\n  heading?: string;\n  components?: string;\n  component?: string;\n  componentHeader?: string;\n  componentQty?: string;\n  componentName?: string;\n  typeBadge?: string;\n  serviceBadge?: string;\n  digitalBadge?: string;\n  variantPicker?: string;\n  variantOption?: string;\n  variantOptionSelected?: string;\n  variantAdjustment?: string;\n  summary?: string;\n  savings?: string;\n}\n\nexport interface BundleSelectorProps {\n  components: BundleComponentView[];\n  bundlePrice?: Money;\n  discountValue?: Money;\n  pricingType?: BundlePriceType;\n  currency?: CurrencyCode;\n  onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n  onPriceChange?: (price: number) => void;\n  onReady?: (ready: boolean) => void;\n  className?: string;\n  classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n  components,\n  bundlePrice,\n  discountValue,\n  pricingType,\n  currency,\n  onSelectionsChange,\n  onPriceChange,\n  onReady,\n  className,\n  classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n  const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n  const lastComponentIds = useRef(\"\");\n\n  useEffect(() => {\n    const ids = components.map((c) => c.id).sort().join();\n    if (ids === lastComponentIds.current) return;\n    lastComponentIds.current = ids;\n\n    const defaults: Record<string, string> = {};\n    for (const comp of components) {\n      if (comp.variant_id) {\n        defaults[comp.id] = comp.variant_id;\n      } else if (comp.available_variants.length > 0) {\n        const defaultVariant =\n          comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n        if (defaultVariant) {\n          defaults[comp.id] = defaultVariant.id;\n        }\n      }\n    }\n    setVariantChoices(defaults);\n  }, [components]);\n\n  const selections = useMemo((): BundleSelectionInput[] => {\n    return components.map((comp) => ({\n      component_id: comp.id,\n      variant_id: variantChoices[comp.id],\n      quantity: comp.quantity,\n    }));\n  }, [components, variantChoices]);\n\n  useEffect(() => {\n    onSelectionsChange(selections);\n  }, [selections, onSelectionsChange]);\n\n  useEffect(() => {\n    onReady?.(components.length > 0 && selections.length > 0);\n  }, [components, selections, onReady]);\n\n  const totalPrice = useMemo(() => {\n    if (pricingType === \"fixed\" && bundlePrice) {\n      return parsePrice(bundlePrice);\n    }\n    const componentsTotal = components.reduce((sum, comp) => {\n      return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n    }, 0);\n    if (pricingType === \"percentage_discount\" && discountValue) {\n      return componentsTotal * (1 - parsePrice(discountValue) / 100);\n    }\n    if (pricingType === \"fixed_discount\" && discountValue) {\n      return componentsTotal - parsePrice(discountValue);\n    }\n    return componentsTotal;\n  }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n  useEffect(() => {\n    onPriceChange?.(totalPrice);\n  }, [totalPrice, onPriceChange]);\n\n  const handleVariantChange = useCallback(\n    (componentId: string, variantId: string) => {\n      setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n    },\n    [],\n  );\n\n  if (components.length === 0) {\n    return null;\n  }\n\n  return (\n    <div data-cimplify-bundle-selector className={cn(\"space-y-4\", className, classNames?.root)}>\n      <div\n        data-cimplify-bundle-heading\n        className={cn(\"flex items-center justify-between py-3\", classNames?.heading)}\n      >\n        <span className=\"text-base font-bold\">Included in this bundle</span>\n      </div>\n\n      <div data-cimplify-bundle-components className={cn(\"divide-y divide-border\", classNames?.components)}>\n        {components.map((comp) => (\n          <BundleComponentCard\n            key={comp.id}\n            component={comp}\n            selectedVariantId={variantChoices[comp.id]}\n            onVariantChange={(variantId) =>\n              handleVariantChange(comp.id, variantId)\n            }\n            currency={currency}\n            classNames={classNames}\n          />\n        ))}\n      </div>\n\n      {bundlePrice && (\n        <div\n          data-cimplify-bundle-summary\n          className={cn(\"border-t border-border pt-4 flex justify-between text-sm\", classNames?.summary)}\n        >\n          <span className=\"text-muted-foreground\">Bundle price</span>\n          <Price amount={bundlePrice} currency={currency} className=\"font-medium text-primary\" />\n        </div>\n      )}\n      {discountValue && (\n        <div\n          data-cimplify-bundle-savings\n          className={cn(\"flex justify-between text-sm\", classNames?.savings)}\n        >\n          <span className=\"text-muted-foreground\">You save</span>\n          <Price amount={discountValue} currency={currency} className=\"text-green-600 font-medium\" />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction getComponentPrice(\n  component: BundleComponentView,\n  selectedVariantId: string | undefined,\n): number {\n  if (!selectedVariantId || component.available_variants.length === 0) {\n    return parsePrice(component.effective_price);\n  }\n  if (selectedVariantId === component.variant_id) {\n    return parsePrice(component.effective_price);\n  }\n  const bakedAdj = component.variant_id\n    ? component.available_variants.find((v) => v.id === component.variant_id)\n    : undefined;\n  const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n  if (!selectedAdj) return parsePrice(component.effective_price);\n  return parsePrice(component.effective_price)\n    - parsePrice(bakedAdj?.price_adjustment ?? \"0\")\n    + parsePrice(selectedAdj.price_adjustment);\n}\n\nfunction formatDuration(minutes: number, unit?: DurationUnit): string {\n  if (unit === \"hours\" || (!unit && minutes >= 60 && minutes % 60 === 0)) {\n    const h = Math.round(minutes / 60);\n    return `${h}h`;\n  }\n  if (unit === \"days\" || unit === \"nights\") {\n    const d = Math.round(minutes / 1440);\n    return `${d}${unit === \"nights\" ? \"n\" : \"d\"}`;\n  }\n  return `${minutes}min`;\n}\n\ninterface BundleComponentCardProps {\n  component: BundleComponentView;\n  selectedVariantId?: string;\n  onVariantChange: (variantId: string) => void;\n  currency?: CurrencyCode;\n  classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n  component,\n  selectedVariantId,\n  onVariantChange,\n  currency,\n  classNames,\n}: BundleComponentCardProps): React.ReactElement {\n  const idPrefix = useId();\n  const showVariantPicker =\n    component.allow_variant_choice && component.available_variants.length > 1;\n\n  const displayPrice = useMemo(\n    () => getComponentPrice(component, selectedVariantId),\n    [component, selectedVariantId],\n  );\n\n  const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n  return (\n    <div\n      data-cimplify-bundle-component\n      className={cn(\"py-4\", classNames?.component)}\n    >\n      <div\n        data-cimplify-bundle-component-header\n        className={cn(\"flex items-center justify-between gap-3\", classNames?.componentHeader)}\n      >\n        <div className=\"flex items-center gap-2\">\n          {component.quantity > 1 && (\n            <span\n              data-cimplify-bundle-component-qty\n              className={cn(\"text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded\", classNames?.componentQty)}\n            >\n              &times;{component.quantity}\n            </span>\n          )}\n          <span\n            id={labelId}\n            data-cimplify-bundle-component-name\n            className={cn(\"text-sm\", classNames?.componentName)}\n          >\n            {component.product_name}\n          </span>\n          {component.product_type === \"service\" && (\n            <span\n              data-cimplify-bundle-type-badge=\"service\"\n              className={cn(\n                \"text-[10px] uppercase tracking-wider font-medium text-blue-600\",\n                classNames?.typeBadge,\n                classNames?.serviceBadge,\n              )}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"inline-block w-3 h-3 mr-0.5 -mt-px\" aria-hidden=\"true\">\n                <circle cx=\"8\" cy=\"8\" r=\"7\" stroke=\"currentColor\" strokeWidth=\"1.5\"/>\n                <path d=\"M8 4v4l2.5 1.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n              </svg>\n              Service{component.duration_minutes != null && (\n                <> &middot; {formatDuration(component.duration_minutes, component.duration_unit)}</>\n              )}\n            </span>\n          )}\n          {component.product_type === \"digital\" && (\n            <span\n              data-cimplify-bundle-type-badge=\"digital\"\n              className={cn(\n                \"text-[10px] uppercase tracking-wider font-medium text-violet-600\",\n                classNames?.typeBadge,\n                classNames?.digitalBadge,\n              )}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"inline-block w-3 h-3 mr-0.5 -mt-px\" aria-hidden=\"true\">\n                <path d=\"M9 2L4 9h4l-1 5 5-7H8l1-5z\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n              </svg>\n              Digital\n            </span>\n          )}\n        </div>\n        <span className=\"text-sm text-muted-foreground\">\n          <Price amount={displayPrice} currency={currency} />\n        </span>\n      </div>\n\n      {showVariantPicker && (\n        <RadioGroup\n          aria-labelledby={labelId}\n          value={selectedVariantId ?? \"\"}\n          onValueChange={(value) => {\n            onVariantChange(value);\n          }}\n          data-cimplify-bundle-variant-picker\n          className={cn(\"mt-3 divide-y divide-border\", classNames?.variantPicker)}\n        >\n          {component.available_variants.map((variant: BundleComponentVariantView) => {\n            const isSelected = selectedVariantId === variant.id;\n            const adjustment = parsePrice(variant.price_adjustment);\n\n            return (\n              <Radio.Root\n                key={variant.id}\n                value={variant.id}\n                data-cimplify-bundle-variant-option\n                data-selected={isSelected || undefined}\n                className={cn(\n                  \"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer\",\n                  isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n                )}\n              >\n                <span\n                  className={cn(\n                    \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n                    isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n                  )}\n                >\n                  {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n                </span>\n                <span className=\"flex-1 text-sm\">\n                  {variant.display_name}\n                </span>\n                {adjustment !== 0 && (\n                  <span\n                    data-cimplify-bundle-variant-adjustment\n                    className={cn(\"text-sm text-muted-foreground\", classNames?.variantAdjustment)}\n                  >\n                    {adjustment > 0 ? \"+\" : \"\"}\n                    <Price amount={variant.price_adjustment} currency={currency} />\n                  </span>\n                )}\n              </Radio.Root>\n            );\n          })}\n        </RadioGroup>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}
