{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search",
  "title": "Search filter helpers",
  "description": "UI-friendly search filter state, conversion to the SDK SearchFilters payload, option lists, color presets, and hex helpers.",
  "dependencies": [
    "@channel3/sdk"
  ],
  "files": [
    {
      "path": "registry/default/lib/search.ts",
      "content": "import type {\n  Brand,\n  CategoryAttribute,\n  CategorySummary,\n  OfferAvailabilityStatus,\n  SearchFilters,\n  Website,\n} from \"@channel3/sdk/resources\";\n\n/** Gender values accepted by the search filter (`unisex` products are matched implicitly). */\nexport type GenderFilter = NonNullable<SearchFilters[\"gender\"]>;\nexport type AgeFilter = NonNullable<SearchFilters[\"age\"]>[number];\nexport type ConditionFilter = NonNullable<SearchFilters[\"conditions\"]>[number];\nexport type AvailabilityFilterValue = OfferAvailabilityStatus;\nexport type LengthUnit = NonNullable<NonNullable<SearchFilters[\"dimensions\"]>[\"length\"]>[\"unit\"];\nexport type WeightUnit = NonNullable<NonNullable<SearchFilters[\"dimensions\"]>[\"weight\"]>[\"unit\"];\n\nexport interface ColorFilter {\n  hex: string;\n  percentage?: number | null;\n}\n\nexport interface DimensionRange {\n  min: number | null;\n  max: number | null;\n}\n\n/** `length`/`width`/`height` share `lengthUnit`; `weight` uses `weightUnit`. */\nexport interface DimensionsFilter {\n  length: DimensionRange;\n  width: DimensionRange;\n  height: DimensionRange;\n  weight: DimensionRange;\n  lengthUnit: LengthUnit;\n  weightUnit: WeightUnit;\n}\n\n/**\n * UI-friendly mirror of the SDK {@link SearchFilters}. Components read and write\n * this shape; {@link toSearchFilters} converts it to the API payload on the\n * consumer's server. Brand and category objects are kept whole (not just ids)\n * so chips can show names/logos without an extra lookup, and\n * `attributesByCategory` caches the attribute definitions of the selected\n * categories so the attribute field can render without re-fetching.\n */\nexport interface SearchFiltersState {\n  price: { minPrice: number | null; maxPrice: number | null };\n  gender: GenderFilter | null;\n  age: AgeFilter[];\n  condition: ConditionFilter | null;\n  availability: OfferAvailabilityStatus[];\n  colors: ColorFilter[];\n  brands: Brand[];\n  websites: Website[];\n  categories: CategorySummary[];\n  attributesByCategory: Record<string, CategoryAttribute[]>;\n  /** Selected attribute values keyed by attribute slug (OR within, AND across keys). */\n  attributes: Record<string, string[]>;\n  dimensions: DimensionsFilter;\n}\n\nexport const DEFAULT_LENGTH_UNIT: LengthUnit = \"in\";\nexport const DEFAULT_WEIGHT_UNIT: WeightUnit = \"lb\";\n\nexport interface DefaultDimensionUnits {\n  lengthUnit?: LengthUnit;\n  weightUnit?: WeightUnit;\n}\n\nexport function createEmptyFilters(units?: DefaultDimensionUnits): SearchFiltersState {\n  return {\n    price: { minPrice: null, maxPrice: null },\n    gender: null,\n    age: [],\n    condition: null,\n    availability: [],\n    colors: [],\n    brands: [],\n    websites: [],\n    categories: [],\n    attributesByCategory: {},\n    attributes: {},\n    dimensions: {\n      length: { min: null, max: null },\n      width: { min: null, max: null },\n      height: { min: null, max: null },\n      weight: { min: null, max: null },\n      lengthUnit: units?.lengthUnit ?? DEFAULT_LENGTH_UNIT,\n      weightUnit: units?.weightUnit ?? DEFAULT_WEIGHT_UNIT,\n    },\n  };\n}\n\nexport const EMPTY_FILTERS: SearchFiltersState = createEmptyFilters();\n\nexport const GENDER_OPTIONS: ReadonlyArray<{ value: GenderFilter; label: string }> = [\n  { value: \"female\", label: \"Women\" },\n  { value: \"male\", label: \"Men\" },\n];\n\nexport const AGE_OPTIONS: ReadonlyArray<{ value: AgeFilter; label: string }> = [\n  { value: \"adult\", label: \"Adult\" },\n  { value: \"kids\", label: \"Kids\" },\n  { value: \"toddler\", label: \"Toddler\" },\n  { value: \"infant\", label: \"Infant\" },\n  { value: \"newborn\", label: \"Newborn\" },\n];\n\nexport const CONDITION_OPTIONS: ReadonlyArray<{ value: ConditionFilter; label: string }> = [\n  { value: \"new\", label: \"New\" },\n  { value: \"used\", label: \"Used\" },\n];\n\nexport const AVAILABILITY_OPTIONS: ReadonlyArray<{ value: OfferAvailabilityStatus; label: string }> = [\n  { value: \"InStock\", label: \"In stock\" },\n  { value: \"OutOfStock\", label: \"Out of stock\" },\n];\n\nexport const LENGTH_UNIT_OPTIONS: ReadonlyArray<{ value: LengthUnit; label: string }> = [\n  { value: \"mm\", label: \"mm\" },\n  { value: \"cm\", label: \"cm\" },\n  { value: \"m\", label: \"m\" },\n  { value: \"in\", label: \"in\" },\n  { value: \"ft\", label: \"ft\" },\n];\n\nexport const WEIGHT_UNIT_OPTIONS: ReadonlyArray<{ value: WeightUnit; label: string }> = [\n  { value: \"mg\", label: \"mg\" },\n  { value: \"g\", label: \"g\" },\n  { value: \"kg\", label: \"kg\" },\n  { value: \"oz\", label: \"oz\" },\n  { value: \"lb\", label: \"lb\" },\n];\n\nconst HEX_PATTERN = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\n\nexport function isValidHex(value: string): boolean {\n  return HEX_PATTERN.test(value.trim());\n}\n\nexport function normalizeHex(value: string): string | null {\n  const trimmed = value.trim();\n  if (!isValidHex(trimmed)) {\n    return null;\n  }\n  let hex = trimmed.replace(/^#/, \"\").toLowerCase();\n  if (hex.length === 3) {\n    hex = hex\n      .split(\"\")\n      .map((char) => char + char)\n      .join(\"\");\n  }\n  return `#${hex}`;\n}\n\nfunction hasDimensionBound(range: DimensionRange): boolean {\n  return range.min != null || range.max != null;\n}\n\nfunction countDimensions(dimensions: DimensionsFilter): number {\n  return [dimensions.length, dimensions.width, dimensions.height, dimensions.weight].filter(\n    hasDimensionBound,\n  ).length;\n}\n\nexport function facetCounts(state: SearchFiltersState) {\n  const attributes = Object.values(state.attributes).reduce(\n    (sum, values) => sum + values.length,\n    0,\n  );\n  return {\n    price: state.price.minPrice != null || state.price.maxPrice != null ? 1 : 0,\n    gender: state.gender ? 1 : 0,\n    age: state.age.length,\n    condition: state.condition ? 1 : 0,\n    availability: state.availability.length,\n    colors: state.colors.length,\n    brands: state.brands.length,\n    websites: state.websites.length,\n    categories: state.categories.length,\n    attributes,\n    dimensions: countDimensions(state.dimensions),\n  };\n}\n\nexport function countActiveFilters(state: SearchFiltersState): number {\n  return Object.values(facetCounts(state)).reduce((sum, count) => sum + count, 0);\n}\n\nexport function attributeHasValues(attribute: CategoryAttribute): boolean {\n  return (attribute.values?.length ?? 0) > 0;\n}\n\nexport function deriveAttributes(\n  categories: CategorySummary[],\n  byCategory: Record<string, CategoryAttribute[]>,\n  attributes: Record<string, string[]>,\n): Pick<SearchFiltersState, \"attributesByCategory\" | \"attributes\"> {\n  const ordered: Record<string, CategoryAttribute[]> = {};\n  const valid = new Set<string>();\n  for (const category of categories) {\n    const defs = byCategory[category.slug] ?? [];\n    ordered[category.slug] = defs;\n    for (const attribute of defs) {\n      valid.add(attribute.slug);\n    }\n  }\n  const prunedAttributes = Object.fromEntries(\n    Object.entries(attributes).filter(([key]) => valid.has(key)),\n  );\n  return { attributesByCategory: ordered, attributes: prunedAttributes };\n}\n\nexport function categoryAttributeGroups(\n  filters: SearchFiltersState,\n): Array<{ category: CategorySummary; attributes: CategoryAttribute[] }> {\n  const rendered = new Set<string>();\n  return filters.categories\n    .map((category) => {\n      const attributes = (filters.attributesByCategory[category.slug] ?? []).filter(\n        (attribute) => attributeHasValues(attribute) && !rendered.has(attribute.slug),\n      );\n      attributes.forEach((attribute) => rendered.add(attribute.slug));\n      return { category, attributes };\n    })\n    .filter((group) => group.attributes.length > 0);\n}\n\nexport function countCategoryAttributes(\n  filters: SearchFiltersState,\n  attributes: CategoryAttribute[],\n): number {\n  return attributes.reduce(\n    (sum, attribute) => sum + (filters.attributes[attribute.slug]?.length ?? 0),\n    0,\n  );\n}\n\n/**\n * Set one color's target share, auto-balancing so the palette never sums past\n * 100%. Raising a color above the remaining budget scales the *other* targeted\n * colors down proportionally; untargeted colors (`percentage == null`) are left\n * alone. Pass `null` to clear a color's target without touching the others.\n */\nexport function setColorPercentage(\n  colors: ColorFilter[],\n  hex: string,\n  percentage: number | null,\n): ColorFilter[] {\n  if (percentage == null) {\n    return colors.map((color) => (color.hex === hex ? { ...color, percentage: null } : color));\n  }\n\n  const target = Math.max(0, Math.min(1, percentage));\n  const budget = 1 - target;\n  const otherTotal = colors.reduce(\n    (sum, color) => (color.hex === hex ? sum : sum + (color.percentage ?? 0)),\n    0,\n  );\n  const scale = otherTotal > budget && otherTotal > 0 ? budget / otherTotal : 1;\n\n  return colors.map((color) => {\n    if (color.hex === hex) {\n      return { ...color, percentage: target };\n    }\n    if (color.percentage == null || scale === 1) {\n      return color;\n    }\n    // Floor to a whole percent so the rounded chips can't visibly exceed 100%.\n    const scaled = Math.floor(color.percentage * scale * 100) / 100;\n    return { ...color, percentage: scaled <= 0 ? null : scaled };\n  });\n}\n\nexport function setAttributeValues(\n  attributes: Record<string, string[]>,\n  slug: string,\n  values: string[],\n): Record<string, string[]> {\n  const next = { ...attributes };\n  if (values.length === 0) {\n    delete next[slug];\n  } else {\n    next[slug] = values;\n  }\n  return next;\n}\n\nfunction toDimensionRange<U extends LengthUnit | WeightUnit>(\n  range: DimensionRange,\n  unit: U,\n): { unit: U; min?: number; max?: number } | null {\n  if (!hasDimensionBound(range)) {\n    return null;\n  }\n  return {\n    unit,\n    ...(range.min != null ? { min: range.min } : {}),\n    ...(range.max != null ? { max: range.max } : {}),\n  };\n}\n\nfunction toDimensionsFilter(dimensions: DimensionsFilter): NonNullable<SearchFilters[\"dimensions\"]> | null {\n  const { lengthUnit, weightUnit } = dimensions;\n  const length = toDimensionRange(dimensions.length, lengthUnit);\n  const width = toDimensionRange(dimensions.width, lengthUnit);\n  const height = toDimensionRange(dimensions.height, lengthUnit);\n  const weight = toDimensionRange(dimensions.weight, weightUnit);\n  if (!length && !width && !height && !weight) {\n    return null;\n  }\n  return {\n    ...(length ? { length } : {}),\n    ...(width ? { width } : {}),\n    ...(height ? { height } : {}),\n    ...(weight ? { weight } : {}),\n  };\n}\n\n/**\n * Convert the UI filter state into the SDK {@link SearchFilters} payload,\n * dropping empty facets so the request stays minimal. Call this on your server\n * before handing the result to `client.products.search`.\n */\nexport function toSearchFilters(state: SearchFiltersState): SearchFilters {\n  const filters: SearchFilters = {};\n\n  const { minPrice, maxPrice } = state.price;\n  if (minPrice != null || maxPrice != null) {\n    filters.price = {\n      ...(minPrice != null ? { min_price: minPrice } : {}),\n      ...(maxPrice != null ? { max_price: maxPrice } : {}),\n    };\n  }\n  if (state.gender) {\n    filters.gender = state.gender;\n  }\n  if (state.age.length > 0) {\n    filters.age = state.age;\n  }\n  if (state.condition) {\n    filters.conditions = [state.condition];\n  }\n  if (state.availability.length > 0) {\n    filters.availability = state.availability;\n  }\n  if (state.colors.length > 0) {\n    filters.colors = {\n      palette: state.colors.map((color) => ({\n        hex: color.hex,\n        ...(color.percentage != null ? { percentage: color.percentage } : {}),\n      })),\n    };\n  }\n  if (state.brands.length > 0) {\n    filters.brand_ids = state.brands.map((brand) => brand.id);\n  }\n  if (state.websites.length > 0) {\n    filters.website_ids = state.websites.map((website) => website.id);\n  }\n  if (state.categories.length > 0) {\n    filters.category_ids = state.categories.map((category) => category.slug);\n  }\n  const attributeKeys = Object.keys(state.attributes);\n  if (attributeKeys.length > 0) {\n    filters.attributes = state.attributes;\n  }\n  const dimensions = toDimensionsFilter(state.dimensions);\n  if (dimensions) {\n    filters.dimensions = dimensions;\n  }\n\n  return filters;\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:lib"
}