{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "appointment-picker",
  "type": "registry:ui",
  "title": "AppointmentPicker",
  "description": "انتخاب نوبت",
  "dependencies": [
    "@kashi-ui/persian@^1.8.0"
  ],
  "registryDependencies": [
    "https://kashi.sepantapouya.com/r/cx.json",
    "https://kashi.sepantapouya.com/r/kashi-provider.json"
  ],
  "files": [
    {
      "path": "registry/kashi/AppointmentPicker.tsx",
      "type": "registry:ui",
      "target": "components/kashi/AppointmentPicker.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from \"react\";\nimport { JALALI_MONTHS, WEEKDAYS, formatJalali, formatTime, getIranHoliday, parseTime, toFaDigits, toISODate, toJalali } from \"@kashi-ui/persian\";\nimport { cx } from \"./cx\";\nimport { useKashi } from \"./KashiProvider\";\n\nexport interface AppointmentSlot {\n  /** ساعت به شکل HH:MM */\n  time: string;\n  /** @default true */\n  available?: boolean;\n}\n\nexport interface AppointmentValue {\n  date: Date;\n  /** HH:MM */\n  time: string;\n}\n\nexport interface AppointmentPickerProps {\n  value?: AppointmentValue | null;\n  defaultValue?: AppointmentValue | null;\n  onChange?: (value: AppointmentValue) => void;\n  /** اولین روز @default امروز */\n  start?: Date;\n  /** تعداد روزهای قابل انتخاب @default 7 */\n  days?: number;\n  /** ساعت شروع کار @default \"09:00\" */\n  from?: string;\n  /** ساعت پایان کار (آخرین نوبت پیش از آن) @default \"17:00\" */\n  to?: string;\n  /** طول هر نوبت به دقیقه @default 30 */\n  step?: number;\n  /** نوبت‌های پرشده: «YYYY-MM-DD» ← ساعت‌ها */\n  booked?: Record<string, string[]>;\n  /** یا ساعت‌های هر روز به‌طور کامل؛ به‌جای from، to، step و booked */\n  getSlots?: (date: Date) => (string | AppointmentSlot)[];\n  /** روزهای تعطیل: true یعنی جمعه‌ها و تعطیلات رسمی ثابت؛ یا تابع دلخواه @default true */\n  closed?: boolean | ((date: Date) => boolean);\n  /** @default \"انتخاب نوبت\" */\n  label?: string;\n  /** با name دو فیلد name_date (YYYY-MM-DD) و name_time در فرم ارسال می‌شوند */\n  name?: string;\n  /** @default \"fa\" */\n  digits?: \"fa\" | \"latin\";\n  className?: string;\n}\n\nconst PERIODS: [\"morning\" | \"afternoon\" | \"evening\", number][] = [[\"morning\", 12], [\"afternoon\", 17], [\"evening\", 24]];\nconst day = (d: Date, add = 0) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + add);\nconst same = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\nconst minutes = (t: string) => {\n  const p = parseTime(t);\n  return p ? p.hours * 60 + p.minutes : 0;\n};\nconst Clock = () => (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"7\" /><path d=\"M10 6v4l2.5 2\" /></svg>\n);\n\n/** ساعت‌های یک روز: از getSlots، یا از from تا to با گام step؛ ساعت‌های گذشتهٔ امروز پر حساب می‌شوند */\nexport function appointmentSlots(\n  date: Date,\n  { from = \"09:00\", to = \"17:00\", step = 30, booked = {}, getSlots, now = new Date() }: Pick<AppointmentPickerProps, \"from\" | \"to\" | \"step\" | \"booked\" | \"getSlots\"> & { now?: Date },\n): Required<AppointmentSlot>[] {\n  let list: Required<AppointmentSlot>[];\n  if (getSlots) list = getSlots(date).map((s) => (typeof s === \"string\" ? { time: s, available: true } : { time: s.time, available: s.available ?? true }));\n  else {\n    const taken = new Set(booked[toISODate(date)] ?? []);\n    list = [];\n    for (let m = minutes(from); m + step <= minutes(to); m += step) {\n      const time = formatTime({ hours: Math.floor(m / 60), minutes: m % 60 });\n      list.push({ time, available: !taken.has(time) });\n    }\n  }\n  if (same(date, now)) {\n    const nowMin = now.getHours() * 60 + now.getMinutes();\n    list = list.map((s) => (minutes(s.time) <= nowMin ? { ...s, available: false } : s));\n  }\n  return list;\n}\n\n/**\n * انتخاب نوبت: روزهای شمسی در یک ردیف (با تعداد نوبت خالی) و ساعت‌های همان روز در گروه‌های صبح، بعدازظهر و عصر.\n * جمعه‌ها و تعطیلات رسمی بسته‌اند، نوبت‌های پر و ساعت‌های گذشته غیرفعال‌اند و کلیدهای جهت بین روزها و ساعت‌ها حرکت می‌کنند.\n */\nexport function AppointmentPicker({\n  value, defaultValue = null, onChange, start, days = 7, from, to, step, booked, getSlots, closed = true,\n  label, name, digits, className,\n}: AppointmentPickerProps) {\n  const id = useId();\n  const [inner, setInner] = useState<AppointmentValue | null>(defaultValue);\n  const selected = value !== undefined ? value : inner;\n  // ساعت فعلی برای غیرفعال کردن نوبت‌های گذشتهٔ امروز؛ هر دقیقه به‌روز می‌شود\n  const [now, setNow] = useState(() => new Date());\n  useEffect(() => {\n    const t = setInterval(() => setNow(new Date()), 60_000);\n    return () => clearInterval(t);\n  }, []);\n  const { messages: M, digits: D, locale, weekdays: WD, months: MONTHS } = useKashi();\n  const A = M.appointment;\n  digits ??= D;\n  label ??= A.label;\n  const n = (v: number | string) => (digits === \"fa\" ? toFaDigits(String(v)) : String(v));\n  const first = day(start ?? now);\n  const list = useMemo(() => Array.from({ length: days }, (_, i) => {\n    const d = day(first, i);\n    const isClosed = typeof closed === \"function\" ? closed(d) : closed ? !!getIranHoliday(d, { locale }) : false;\n    const slots = isClosed ? [] : appointmentSlots(d, { from, to, step, booked, getSlots, now });\n    return { date: d, closed: isClosed, slots, free: slots.filter((s) => s.available).length };\n  }), [first.getTime(), days, closed, from, to, step, booked, getSlots, now.getHours(), now.getMinutes()]);\n\n  const [active, setActive] = useState(() => {\n    const i = selected ? list.findIndex((x) => same(x.date, selected.date)) : -1;\n    return i >= 0 ? i : Math.max(0, list.findIndex((x) => x.free > 0));\n  });\n  const current = list[active] ?? list[0];\n  const daysRef = useRef<HTMLDivElement>(null);\n  const timesRef = useRef<HTMLDivElement>(null);\n\n  const label2 = (d: Date) => (same(d, now) ? M.common.today : same(d, day(now, 1)) ? M.common.tomorrow : WD[(d.getDay() + 1) % 7]);\n  const pick = (time: string) => {\n    const v = { date: current.date, time };\n    if (value === undefined) setInner(v);\n    onChange?.(v);\n  };\n\n  // فوکوس گردشی: در RTL فلش چپ یعنی بعدی\n  const roving = (e: KeyboardEvent<HTMLElement>, box: HTMLElement | null, onMove?: (i: number) => void) => {\n    const items = [...(box?.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]:not(:disabled)') ?? [])];\n    const i = items.indexOf(document.activeElement as HTMLButtonElement);\n    if (i < 0) return;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const step2 = ({ ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key];\n    const to2 = step2 ? (i + step2 + items.length) % items.length : e.key === \"Home\" ? 0 : e.key === \"End\" ? items.length - 1 : -1;\n    if (to2 < 0) return;\n    e.preventDefault();\n    items[to2].focus();\n    onMove?.(Number(items[to2].dataset.i));\n  };\n\n  const groups = PERIODS.map(([title, until], k) => ({\n    title,\n    slots: current.slots.filter((s) => {\n      const h = minutes(s.time) / 60;\n      return h < until && h >= (k ? PERIODS[k - 1][1] : 0);\n    }),\n  })).filter((g) => g.slots.length);\n  const isSelectedDay = !!selected && same(selected.date, current.date);\n  const timeFocus = isSelectedDay ? selected!.time : current.slots.find((s) => s.available)?.time;\n\n  return (\n    <div className={cx(\"k-appt\", className)} role=\"group\" aria-labelledby={`${id}-label`}>\n      <p className=\"k-label\" id={`${id}-label`} style={{ margin: 0 }}>{label}</p>\n      <div className=\"k-appt__days\" role=\"radiogroup\" aria-label={A.day} ref={daysRef} onKeyDown={(e) => roving(e, daysRef.current, setActive)}>\n        {list.map((x, i) => {\n          const { jm, jd } = toJalali(x.date);\n          const full = formatJalali(x.date, { style: \"full\", digits, locale });\n          return (\n            <button\n              key={x.date.getTime()}\n              type=\"button\"\n              role=\"radio\"\n              data-i={i}\n              className=\"k-appt__day\"\n              aria-checked={i === active}\n              tabIndex={i === active ? 0 : -1}\n              disabled={x.closed}\n              aria-label={`${full}${M.common.list}${x.closed ? A.closed : x.free ? A.freeLong(n(x.free)) : A.none}`}\n              onClick={() => setActive(i)}\n            >\n              <span className=\"k-appt__weekday\" aria-hidden=\"true\">{label2(x.date)}</span>\n              <span className=\"k-appt__date\" aria-hidden=\"true\">{n(jd)}</span>\n              <span className=\"k-appt__month\" aria-hidden=\"true\">{MONTHS[jm - 1]}</span>\n              <span className=\"k-appt__free\" aria-hidden=\"true\">{x.closed ? A.closed : x.free ? A.free(n(x.free)) : A.full}</span>\n            </button>\n          );\n        })}\n      </div>\n      <div className=\"k-appt__slots\" ref={timesRef} onKeyDown={(e) => roving(e, timesRef.current)}>\n        {current.closed || !current.free ? (\n          <p className=\"k-appt__empty\">{current.closed ? A.closedDay : A.noSlots}</p>\n        ) : groups.map((g) => (\n          <div className=\"k-appt__group\" key={g.title}>\n            <p className=\"k-appt__period\" id={`${id}-${g.title}`}>{A[g.title]}</p>\n            <div className=\"k-appt__times\" role=\"radiogroup\" aria-labelledby={`${id}-${g.title}`}>\n              {g.slots.map((s) => {\n                const on = isSelectedDay && selected!.time === s.time;\n                return (\n                  <button\n                    key={s.time}\n                    type=\"button\"\n                    role=\"radio\"\n                    className=\"k-appt__time\"\n                    aria-checked={on}\n                    tabIndex={s.time === timeFocus ? 0 : -1}\n                    disabled={!s.available}\n                    aria-label={`${A.time(n(s.time))}${s.available ? \"\" : A.booked}`}\n                    onClick={() => pick(s.time)}\n                  >\n                    {n(s.time)}\n                  </button>\n                );\n              })}\n            </div>\n          </div>\n        ))}\n      </div>\n      <p className=\"k-appt__summary\" aria-live=\"polite\">\n        {selected && (\n          <>\n            <Clock />\n            <span>{A.yours} <strong>{A.at(formatJalali(selected.date, { style: \"full\", digits, locale }), n(selected.time))}</strong></span>\n          </>\n        )}\n      </p>\n      {name && (\n        <>\n          <input type=\"hidden\" name={`${name}_date`} value={selected ? toISODate(selected.date) : \"\"} />\n          <input type=\"hidden\" name={`${name}_time`} value={selected?.time ?? \"\"} />\n        </>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}