{
  "name": "slot-picker",
  "title": "SlotPicker",
  "description": "Time slot grid for a single day with morning/afternoon/evening grouping.",
  "type": "component",
  "registryDependencies": [
    "price",
    "cn"
  ],
  "files": [
    {
      "path": "slot-picker.tsx",
      "content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React, { useMemo } from \"react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useAvailableSlots } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SlotPickerClassNames {\n  root?: string;\n  group?: string;\n  groupLabel?: string;\n  slot?: string;\n  slotTime?: string;\n  slotPrice?: string;\n  slotScarcity?: string;\n  loading?: string;\n  empty?: string;\n}\n\nexport interface SlotPickerProps {\n  /** Pre-fetched slots (skips fetch). */\n  slots?: AvailableSlot[];\n  /** Service ID — used to fetch slots when `slots` prop is not provided. */\n  serviceId?: string;\n  /** Date string (YYYY-MM-DD) — used to fetch slots when `slots` prop is not provided. */\n  date?: string;\n  /** Number of participants for capacity-based availability. */\n  participantCount?: number;\n  /** Currently selected slot. */\n  selectedSlot?: AvailableSlot | null;\n  /** Called when a slot is selected. */\n  onSlotSelect?: (slot: AvailableSlot) => void;\n  /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `\"multi_day\"`. */\n  groupByTimeOfDay?: boolean;\n  /** Show price on each slot. Default: true. */\n  showPrice?: boolean;\n  /**\n   * Show a \"N left\" urgency tag when a slot's remaining capacity is at or below\n   * this number (and above zero). Slots at zero capacity render as sold out.\n   * Default: 3. Set to 0 to disable scarcity tags.\n   */\n  lowCapacityThreshold?: number;\n  /** Currency the slot prices are denominated in. */\n  currency?: CurrencyCode;\n  /**\n   * Hide slots whose `start_time` is already in the past. Default: true.\n   * Set to false to show elapsed slots greyed-out (still un-selectable).\n   */\n  hideElapsedSlots?: boolean;\n  /**\n   * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n   * start is sooner than `now + minLeadMinutes` are filtered out (when\n   * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n   */\n  minLeadMinutes?: number;\n  /**\n   * Service scheduling mode. When `\"multi_day\"`, each slot renders as a\n   * stay summary (`\"3 nights: Fri Apr 5, 3:00 PM → Mon Apr 8, 11:00 AM\"`)\n   * instead of the time-of-day label. Defaults to `\"intraday\"`.\n   */\n  schedulingMode?: SchedulingMode;\n  /** Service duration unit — used for the stay summary in multi-day mode. */\n  durationUnit?: DurationUnit;\n  /** Service duration value — used for the stay summary in multi-day mode. */\n  durationValue?: number;\n  /** Text shown when no slots available. */\n  emptyMessage?: string;\n  /**\n   * Phone number offered when a day has no bookable times. A service can be\n   * unbookable for reasons the storefront cannot see, and a bare empty grid\n   * reads as a broken site rather than a shop to call.\n   */\n  contactPhone?: string;\n  className?: string;\n  classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n  label: string;\n  slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): \"morning\" | \"afternoon\" | \"evening\" {\n  const hour = parseInt(timeStr.split(\"T\").pop()?.split(\":\")[0] ?? timeStr.split(\":\")[0], 10);\n  if (hour < 12) return \"morning\";\n  if (hour < 17) return \"afternoon\";\n  return \"evening\";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n  morning: \"Morning\",\n  afternoon: \"Afternoon\",\n  evening: \"Evening\",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n  const groups: Record<string, AvailableSlot[]> = {};\n  for (const slot of slots) {\n    const tod = getTimeOfDay(slot.start_time);\n    if (!groups[tod]) groups[tod] = [];\n    groups[tod].push(slot);\n  }\n  return ([\"morning\", \"afternoon\", \"evening\"] as const)\n    .filter((tod) => groups[tod]?.length)\n    .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n  try {\n    const date = new Date(timeStr);\n    if (!isNaN(date.getTime())) {\n      return date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" });\n    }\n  } catch {\n    // noop\n  }\n\n  const parts = timeStr.split(\":\");\n  if (parts.length >= 2) {\n    const hour = parseInt(parts[0], 10);\n    const minute = parts[1];\n    const ampm = hour >= 12 ? \"PM\" : \"AM\";\n    const displayHour = hour % 12 || 12;\n    return `${displayHour}:${minute} ${ampm}`;\n  }\n  return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n  if (!unit) return value === 1 ? \"day\" : \"days\";\n  const v = value ?? 1;\n  if (unit === \"minutes\") return v === 1 ? \"minute\" : \"minutes\";\n  if (unit === \"hours\") return v === 1 ? \"hour\" : \"hours\";\n  if (unit === \"days\") return v === 1 ? \"day\" : \"days\";\n  if (unit === \"weeks\") return v === 1 ? \"week\" : \"weeks\";\n  if (unit === \"months\") return v === 1 ? \"month\" : \"months\";\n  return unit;\n}\n\nfunction formatStaySummary(\n  slot: AvailableSlot,\n  durationUnit: DurationUnit | undefined,\n  durationValue: number | undefined,\n): string {\n  const start = new Date(slot.start_time);\n  const end = new Date(slot.end_time);\n  const startLabel = start.toLocaleString(undefined, {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"2-digit\",\n  });\n  const endLabel = end.toLocaleString(undefined, {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"2-digit\",\n  });\n  const unitLabel = pluralizeUnit(durationUnit, durationValue);\n  if (durationValue !== undefined) {\n    return `${durationValue} ${unitLabel}: ${startLabel} → ${endLabel}`;\n  }\n  return `${startLabel} → ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n  return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n  slots: slotsProp,\n  serviceId,\n  date,\n  participantCount,\n  selectedSlot,\n  onSlotSelect,\n  groupByTimeOfDay = true,\n  showPrice = true,\n  lowCapacityThreshold = 3,\n  currency,\n  schedulingMode = \"intraday\",\n  durationUnit,\n  durationValue,\n  hideElapsedSlots = true,\n  minLeadMinutes = 0,\n  emptyMessage = \"No available slots\",\n  contactPhone,\n  className,\n  classNames,\n}: SlotPickerProps): React.ReactElement {\n  const isMultiDay = schedulingMode === \"multi_day\";\n  const { slots: fetched, isLoading } = useAvailableSlots(\n    serviceId ?? null,\n    date ?? null,\n    {\n      participantCount,\n      enabled: slotsProp === undefined && !!serviceId && !!date,\n    },\n  );\n\n  const rawSlots = slotsProp ?? fetched;\n  // Drop slots that have already elapsed (or fall within the lead-time\n  // window). Default behaviour because nothing the merchant can do at\n  // the backend stops a client clock from being slightly ahead of the\n  // last availability response — this is the same defence other\n  // booking flows ship by default.\n  const slots = useMemo(() => {\n    if (!hideElapsedSlots) return rawSlots;\n    const cutoff = Date.now() + minLeadMinutes * 60_000;\n    return rawSlots.filter((slot) => {\n      const start = Date.parse(slot.start_time);\n      return Number.isNaN(start) || start >= cutoff;\n    });\n  }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n  if (isLoading && slots.length === 0) {\n    return (\n      <div\n        data-cimplify-slot-picker\n        aria-busy=\"true\"\n        className={cn(className, classNames?.root, classNames?.loading)}\n      />\n    );\n  }\n\n  if (slots.length === 0) {\n    return (\n      <div\n        data-cimplify-slot-picker\n        data-empty\n        className={cn(className, classNames?.root, classNames?.empty)}\n      >\n        <p>{emptyMessage}</p>\n        {contactPhone ? (\n          <p>\n            <a href={`tel:${contactPhone.replace(/\\s+/g, \"\")}`}>Call {contactPhone} to book</a>\n          </p>\n        ) : null}\n      </div>\n    );\n  }\n\n  const groups = groupByTimeOfDay && !isMultiDay\n    ? groupSlots(slots)\n    : [{ label: \"\", slots }];\n\n  const slotsByValue = new Map<string, AvailableSlot>();\n  for (const slot of slots) {\n    slotsByValue.set(slotToValue(slot), slot);\n  }\n\n  const selectedValue = selectedSlot ? slotToValue(selectedSlot) : \"\";\n\n  return (\n    <RadioGroup\n      data-cimplify-slot-picker\n      className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n      value={selectedValue}\n      onValueChange={(value: string) => {\n        const slot = slotsByValue.get(value);\n        // Slots default to available; treat as unavailable only when the\n        // backend explicitly returns `is_available: false`.\n        if (slot && slot.is_available !== false) {\n          onSlotSelect?.(slot);\n        }\n      }}\n    >\n      {groups.map((group) => (\n        <div\n          key={group.label || \"all\"}\n          data-cimplify-slot-group\n          className={cn(\"flex flex-col gap-2\", classNames?.group)}\n        >\n          {group.label && (\n            <div\n              data-cimplify-slot-group-label\n              className={cn(\n                \"text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n                classNames?.groupLabel,\n              )}\n            >\n              {group.label}\n            </div>\n          )}\n          <div\n            className={cn(\n              isMultiDay\n                ? \"flex flex-col gap-2\"\n                : \"grid grid-cols-3 sm:grid-cols-4 gap-2\",\n            )}\n          >\n            {group.slots.map((slot) => {\n              const value = slotToValue(slot);\n              const isSelected =\n                selectedSlot?.start_time === slot.start_time &&\n                selectedSlot?.end_time === slot.end_time;\n              const capacity = slot.capacity_available ?? slot.remaining_capacity;\n              const soldOut = capacity === 0;\n              const unavailable = slot.is_available === false || soldOut;\n              const lowCapacity =\n                lowCapacityThreshold > 0 &&\n                capacity != null &&\n                capacity > 0 &&\n                capacity <= lowCapacityThreshold;\n              return (\n                <Radio.Root\n                  key={value}\n                  value={value}\n                  disabled={unavailable}\n                  data-cimplify-slot\n                  data-selected={isSelected || undefined}\n                  data-unavailable={unavailable || undefined}\n                  className={cn(\n                    \"inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through\",\n                    isMultiDay && \"justify-between text-left\",\n                    classNames?.slot,\n                  )}\n                >\n                  <span data-cimplify-slot-time className={classNames?.slotTime}>\n                    {isMultiDay\n                      ? formatStaySummary(slot, durationUnit, durationValue)\n                      : formatTime(slot.start_time)}\n                  </span>\n                  {showPrice && slot.price && (\n                    <span\n                      data-cimplify-slot-price\n                      className={cn(\"text-xs opacity-70\", classNames?.slotPrice)}\n                    >\n                      <Price amount={slot.price} currency={currency} />\n                    </span>\n                  )}\n                  {lowCapacity && (\n                    <span\n                      data-cimplify-slot-scarcity\n                      className={cn(\"text-xs font-medium text-amber-600\", classNames?.slotScarcity)}\n                    >\n                      {capacity} left\n                    </span>\n                  )}\n                </Radio.Root>\n              );\n            })}\n          </div>\n        </div>\n      ))}\n    </RadioGroup>\n  );\n}\n"
    }
  ]
}
