{
  "name": "stay-picker",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Stay Picker\n * @version: 1.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst DAY_MS = 86_400_000;\nconst WEEKDAYS = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\nconst MONTHS = [\n  \"January\",\n  \"February\",\n  \"March\",\n  \"April\",\n  \"May\",\n  \"June\",\n  \"July\",\n  \"August\",\n  \"September\",\n  \"October\",\n  \"November\",\n  \"December\",\n];\n/** Height of one week row, in px. The pill is drawn in the same units. */\nconst ROW = 52;\n\nexport interface Night {\n  price: number;\n  /** Sold out: you can't sleep here this night, but you can check out. */\n  blocked?: boolean;\n}\n\nexport interface StayPickerProps {\n  /** First month shown, as YYYY-MM. */\n  month?: string;\n  /** Price and availability for one night, by date (YYYY-MM-DD). */\n  getNight?: (date: string) => Night;\n  /** Fewest nights one booking can be. */\n  minNights?: number;\n  currency?: string;\n  onReserve?: (stay: {\n    checkIn: string;\n    checkOut: string;\n    total: number;\n  }) => void;\n  className?: string;\n}\n\n/** Days since 1970, so ranges can cross months. */\nconst dayOf = (y: number, m: number, d: number) => Date.UTC(y, m, d) / DAY_MS;\n\nconst isoOf = (day: number) =>\n  new Date(day * DAY_MS).toISOString().slice(0, 10);\n\nconst shortDate = (day: number) => {\n  const date = new Date(day * DAY_MS);\n  return `${MONTHS[date.getUTCMonth()].slice(0, 3)} ${date.getUTCDate()}`;\n};\n\n/** Seeded so the server and the browser price the same night the same way. */\nfunction demoNight(iso: string): Night {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  const day = dayOf(y, m - 1, d);\n  let seed = (day * 7919) % 2_147_483_647;\n  seed = (seed * 16_807) % 2_147_483_647;\n  const weekday = (new Date(day * DAY_MS).getUTCDay() + 6) % 7;\n  const weekend = weekday === 4 || weekday === 5;\n  const price = 150 + (weekend ? 45 : 0) + (seed % 7) * 8;\n  return { price, blocked: seed % 11 === 0 };\n}\n\nfunction useMonth(initial: string) {\n  const [y, m] = initial.split(\"-\").map(Number);\n  return useState({ year: y, month: m - 1 });\n}\n\ninterface Segment {\n  row: number;\n  from: number;\n  to: number;\n  roundLeft: boolean;\n  roundRight: boolean;\n}\n\nconst ARROW_STEP: Record<string, number> = {\n  ArrowLeft: -1,\n  ArrowRight: 1,\n  ArrowUp: -7,\n  ArrowDown: 7,\n};\n\n/**\n * Where a stay from `from` can end if you aim for `to`: it can't run\n * through a sold-out night, and it can't be shorter than the minimum.\n */\nfunction settleStay(\n  from: number,\n  to: number,\n  minNights: number,\n  blocked: (day: number) => boolean | undefined\n): { end: number | null; note: string | null } {\n  let end = to;\n  let note: string | null = null;\n  for (let d = from; d < to; d++) {\n    if (blocked(d)) {\n      end = d;\n      note = `Sold out from ${shortDate(d)}`;\n      break;\n    }\n  }\n  if (end - from >= minNights) {\n    return { end, note };\n  }\n  for (let d = from; d < from + minNights; d++) {\n    if (blocked(d)) {\n      return {\n        end: null,\n        note: `${minNights}-night minimum, and ${shortDate(d)} is sold out`,\n      };\n    }\n  }\n  return {\n    end: from + minNights,\n    note:\n      to - from < minNights\n        ? `${minNights}-night minimum, so we added a night`\n        : note,\n  };\n}\n\n/**\n * One row of the pill as a clip over a full-width bar, so it grows and\n * shrinks on the GPU. Ends are round only where the stay starts or stops.\n */\nfunction clipFor(s: Segment) {\n  const left = (s.from / 7) * 100;\n  const right = 100 - ((s.to + 1) / 7) * 100;\n  const l = s.roundLeft ? \"22px\" : \"0px\";\n  const r = s.roundRight ? \"22px\" : \"0px\";\n  return `inset(0% ${right}% 0% ${left}% round ${l} ${r} ${r} ${l})`;\n}\n\n/** The stay, cut into one segment per week row it crosses. */\nfunction segmentsFor(\n  start: number | null,\n  end: number | null,\n  first: number,\n  daysIn: number,\n  lead: number\n) {\n  const segments: Segment[] = [];\n  if (start === null || end === null) {\n    return segments;\n  }\n  const a = Math.max(start, first);\n  const b = Math.min(end, first + daysIn - 1);\n  const ia = a - first + lead;\n  const ib = b - first + lead;\n  for (\n    let row = Math.floor(ia / 7);\n    a <= b && row <= Math.floor(ib / 7);\n    row++\n  ) {\n    const from = Math.max(ia, row * 7);\n    const to = Math.min(ib, row * 7 + 6);\n    segments.push({\n      row,\n      from: from % 7,\n      to: to % 7,\n      roundLeft: from === ia && a === start,\n      roundRight: to === ib && b === end,\n    });\n  }\n  return segments;\n}\n\nfunction summaryFor(start: number | null, end: number | null) {\n  if (start === null) {\n    return \"Pick a check-in night\";\n  }\n  if (end === null) {\n    return `Check-in ${shortDate(start)}. Now pick check-out`;\n  }\n  return `${shortDate(start)} – ${shortDate(end)} · ${end - start} nights`;\n}\n\nfunction dayTone(edge: boolean, blocked: boolean | undefined) {\n  if (edge) {\n    return \"\";\n  }\n  return blocked\n    ? \"text-zinc-400 line-through decoration-zinc-300 dark:text-zinc-600 dark:decoration-zinc-700\"\n    : \"text-zinc-900 dark:text-zinc-100\";\n}\n\nfunction priceTone(edge: boolean, cheap: boolean) {\n  if (edge) {\n    return \"text-white/70 dark:text-zinc-900/60\";\n  }\n  return cheap\n    ? \"text-emerald-600 dark:text-emerald-400\"\n    : \"text-zinc-400 dark:text-zinc-500\";\n}\n\nfunction DayCell({\n  day,\n  cell,\n  label,\n  night,\n  cheap,\n  edge,\n  hoverEnd,\n  inStay,\n  focusable,\n  money,\n  onKeyDown,\n}: {\n  day: number;\n  cell: number;\n  label: number;\n  night: Night;\n  cheap: number;\n  edge: boolean;\n  hoverEnd: boolean;\n  inStay: boolean;\n  focusable: boolean;\n  money: (n: number) => string;\n  onKeyDown: (event: KeyboardEvent<HTMLButtonElement>, day: number) => void;\n}) {\n  return (\n    <button\n      aria-label={`${shortDate(day)}, ${night.blocked ? \"sold out\" : `${money(night.price)} a night`}`}\n      aria-pressed={edge}\n      className=\"group absolute flex flex-col items-center justify-center outline-none\"\n      data-day={day}\n      onKeyDown={(event) => onKeyDown(event, day)}\n      style={{\n        left: `${((cell % 7) / 7) * 100}%`,\n        top: Math.floor(cell / 7) * ROW,\n        width: `${100 / 7}%`,\n        height: ROW,\n      }}\n      tabIndex={focusable ? 0 : -1}\n      type=\"button\"\n    >\n      <span\n        className={cn(\n          \"relative flex size-11 flex-col items-center justify-center rounded-full leading-none transition-colors duration-150\",\n          \"group-focus-visible:ring-2 group-focus-visible:ring-zinc-400\",\n          edge && \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\",\n          hoverEnd && \"ring-1 ring-zinc-900 ring-inset dark:ring-zinc-100\",\n          !(edge || inStay || night.blocked) &&\n            \"group-hover:bg-zinc-100 dark:group-hover:bg-zinc-900\"\n        )}\n      >\n        {night.blocked && !edge && (\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-1 rounded-full text-zinc-200 dark:text-zinc-800\"\n            style={{\n              backgroundImage:\n                \"repeating-linear-gradient(135deg, currentColor 0 1px, transparent 1px 5px)\",\n            }}\n          />\n        )}\n        <span\n          className={cn(\n            \"relative font-medium text-[13px] tabular-nums\",\n            dayTone(edge, night.blocked)\n          )}\n        >\n          {label}\n        </span>\n        {!night.blocked && (\n          <span\n            className={cn(\n              \"relative mt-1 text-[10px] tabular-nums\",\n              priceTone(edge, night.price <= cheap)\n            )}\n          >\n            {night.price}\n          </span>\n        )}\n      </span>\n    </button>\n  );\n}\n\n/**\n * A date range for booking stays. Every night shows its price, sold-out\n * nights are hatched, and the stay is drawn as one pill that wraps from\n * row to row as you drag across the weeks.\n */\nexport default function StayPicker({\n  month: initialMonth = \"2026-11\",\n  getNight = demoNight,\n  minNights = 2,\n  currency = \"$\",\n  onReserve,\n  className,\n}: StayPickerProps) {\n  const [view, setView] = useMonth(initialMonth);\n  const [direction, setDirection] = useState(1);\n  const [start, setStart] = useState<number | null>(null);\n  const [end, setEnd] = useState<number | null>(null);\n  const [hover, setHover] = useState<number | null>(null);\n  const [note, setNote] = useState<string | null>(null);\n  const [reserved, setReserved] = useState(false);\n  const reduceMotion = Boolean(useReducedMotion());\n\n  const dragging = useRef(false);\n  // The month grid is keyed, so a ref on it goes null once the old month\n  // finishes leaving; the card itself stays put.\n  const rootRef = useRef<HTMLDivElement>(null);\n  const noteTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => () => clearTimeout(noteTimer.current), []);\n\n  const first = dayOf(view.year, view.month, 1);\n  const daysIn = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate();\n  const lead = (new Date(first * DAY_MS).getUTCDay() + 6) % 7;\n  const rows = Math.ceil((lead + daysIn) / 7);\n  const nights = new Map<number, Night>();\n  const nightOf = (day: number) => {\n    let night = nights.get(day);\n    if (!night) {\n      night = getNight(isoOf(day));\n      nights.set(day, night);\n    }\n    return night;\n  };\n\n  const days = Array.from({ length: daysIn }, (_, i) => first + i);\n  const prices = days\n    .filter((d) => !nightOf(d).blocked)\n    .map((d) => nightOf(d).price);\n  // The month's lowest price is marked, so the good nights stand out.\n  const cheap = prices.length ? Math.min(...prices) : 0;\n\n  const say = (text: string) => {\n    setNote(text);\n    clearTimeout(noteTimer.current);\n    noteTimer.current = setTimeout(() => setNote(null), 2600);\n  };\n\n  const settle = (from: number, to: number, speak: boolean) => {\n    const result = settleStay(from, to, minNights, (d) => nightOf(d).blocked);\n    if (speak && result.note) {\n      say(result.note);\n    }\n    return result.end;\n  };\n\n  const picking = start !== null && end === null;\n  const shownEnd =\n    picking && hover !== null && hover > start\n      ? settle(start, hover, false)\n      : end;\n\n  const begin = (day: number) => {\n    if (nightOf(day).blocked) {\n      say(\"Sold out that night\");\n      return;\n    }\n    setStart(day);\n    setEnd(null);\n    setReserved(false);\n  };\n\n  const choose = (day: number) => {\n    if (start === null || end !== null || day <= start) {\n      begin(day);\n      return;\n    }\n    const out = settle(start, day, true);\n    if (out === null) {\n      return;\n    }\n    setEnd(out);\n    setHover(null);\n  };\n\n  const dayAt = (event: PointerEvent) => {\n    const el = document.elementFromPoint(event.clientX, event.clientY);\n    const cell = el?.closest<HTMLElement>(\"[data-day]\");\n    return cell ? Number(cell.dataset.day) : null;\n  };\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) {\n      return;\n    }\n    const day = dayAt(event);\n    if (day === null) {\n      return;\n    }\n    dragging.current = true;\n    // A press on a fresh day starts a stay; a press while picking ends one.\n    if (start === null || end !== null || day <= start) {\n      begin(day);\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const day = dayAt(event);\n    if (day === null) {\n      return;\n    }\n    if (start !== null && end === null) {\n      setHover(day);\n    }\n  };\n\n  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    if (!dragging.current) {\n      return;\n    }\n    dragging.current = false;\n    const day = dayAt(event);\n    if (day !== null && start !== null && day > start && end === null) {\n      choose(day);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, day: number) => {\n    if (event.key in ARROW_STEP) {\n      event.preventDefault();\n      const next = day + ARROW_STEP[event.key];\n      const focusDay = () =>\n        rootRef.current\n          ?.querySelector<HTMLElement>(`[data-day=\"${next}\"]`)\n          ?.focus();\n      if (next < first || next >= first + daysIn) {\n        // Off the edge of the month: turn the page, then land on the day.\n        go(next < first ? -1 : 1);\n        requestAnimationFrame(focusDay);\n      } else {\n        focusDay();\n      }\n      if (start !== null && end === null) {\n        setHover(next);\n      }\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      choose(day);\n    }\n  };\n\n  const go = (delta: number) => {\n    setDirection(delta);\n    setView(({ year, month }) => {\n      const m = month + delta;\n      return { year: year + Math.floor(m / 12), month: ((m % 12) + 12) % 12 };\n    });\n  };\n\n  const segments = segmentsFor(start, shownEnd ?? null, first, daysIn, lead);\n\n  const stayNights =\n    start !== null && end !== null\n      ? Array.from({ length: end - start }, (_, i) => nightOf(start + i).price)\n      : [];\n  const total = stayNights.reduce((sum, p) => sum + p, 0);\n  const money = (n: number) => `${currency}${n.toLocaleString(\"en-US\")}`;\n\n  const spring = reduceMotion\n    ? { duration: 0 }\n    : { type: \"spring\" as const, duration: 0.35, bounce: 0 };\n  const fade = { duration: reduceMotion ? 0 : 0.18, ease: EASE_OUT };\n\n  const summary = summaryFor(start, end);\n  // The one day Tab lands on: check-in when it's in this month, else the 1st.\n  const tabDay =\n    start !== null && start >= first && start < first + daysIn ? start : first;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-sm select-none rounded-3xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n      ref={rootRef}\n    >\n      <div className=\"flex items-center justify-between px-1\">\n        <div className=\"relative h-5 flex-1 overflow-hidden\">\n          <AnimatePresence custom={direction} initial={false} mode=\"popLayout\">\n            <motion.p\n              animate={{ opacity: 1, x: 0 }}\n              className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\"\n              exit={{ opacity: 0, x: -12 * direction }}\n              initial={{ opacity: 0, x: 12 * direction }}\n              key={`${view.year}-${view.month}`}\n              transition={fade}\n            >\n              {MONTHS[view.month]} {view.year}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n        <div className=\"flex gap-1\">\n          {[-1, 1].map((delta) => (\n            <button\n              aria-label={delta < 0 ? \"Previous month\" : \"Next month\"}\n              className=\"flex size-8 items-center justify-center rounded-full text-zinc-500 outline-none transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n              key={delta}\n              onClick={() => go(delta)}\n              type=\"button\"\n            >\n              {delta < 0 ? (\n                <ChevronLeft aria-hidden className=\"size-4\" />\n              ) : (\n                <ChevronRight aria-hidden className=\"size-4\" />\n              )}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"mt-3 grid grid-cols-7 text-center text-[11px] text-zinc-400 dark:text-zinc-500\">\n        {WEEKDAYS.map((d) => (\n          <span key={d}>{d}</span>\n        ))}\n      </div>\n\n      <AnimatePresence custom={direction} initial={false} mode=\"popLayout\">\n        <motion.div\n          animate={{ opacity: 1, x: 0 }}\n          className=\"mt-1\"\n          exit={{ opacity: 0, x: -16 * direction }}\n          initial={{ opacity: 0, x: 16 * direction }}\n          key={`${view.year}-${view.month}`}\n          transition={fade}\n        >\n          <div\n            className=\"relative touch-none\"\n            onPointerCancel={() => {\n              dragging.current = false;\n            }}\n            onPointerDown={onPointerDown}\n            onPointerLeave={() => setHover(null)}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n            style={{ height: rows * ROW }}\n          >\n            {/* The stay: one pill per row, ends rounded only where the stay\n              really starts and stops, so it reads as a single shape. */}\n            {segments.map((s) => (\n              <motion.div\n                animate={{ clipPath: clipFor(s), opacity: 1 }}\n                aria-hidden=\"true\"\n                className={cn(\n                  \"pointer-events-none absolute inset-x-0 h-11 bg-zinc-100 dark:bg-zinc-800/70\",\n                  picking && \"bg-zinc-100/70 dark:bg-zinc-800/50\"\n                )}\n                initial={{\n                  clipPath: clipFor({\n                    ...s,\n                    to: s.from,\n                    roundLeft: true,\n                    roundRight: true,\n                  }),\n                  opacity: 0,\n                }}\n                key={s.row}\n                style={{ top: s.row * ROW + (ROW - 44) / 2 }}\n                transition={{ ...spring, opacity: fade }}\n              />\n            ))}\n\n            {days.map((day, i) => (\n              <DayCell\n                cell={i + lead}\n                cheap={cheap}\n                day={day}\n                edge={\n                  day === start || day === end || (day === shownEnd && !picking)\n                }\n                focusable={day === tabDay}\n                hoverEnd={picking && day === shownEnd}\n                inStay={\n                  start !== null &&\n                  shownEnd != null &&\n                  day > start &&\n                  day < shownEnd\n                }\n                key={day}\n                label={i + 1}\n                money={money}\n                night={nightOf(day)}\n                onKeyDown={onKeyDown}\n              />\n            ))}\n          </div>\n        </motion.div>\n      </AnimatePresence>\n\n      <div aria-live=\"polite\" className=\"relative mt-2 h-4 px-1 text-xs\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.p\n            animate={{ opacity: 1, y: 0 }}\n            className={cn(\n              note\n                ? \"text-orange-600 dark:text-orange-400\"\n                : \"text-zinc-500 dark:text-zinc-400\"\n            )}\n            exit={{ opacity: 0, y: -4 }}\n            initial={{ opacity: 0, y: 4 }}\n            key={note ?? summary}\n            transition={fade}\n          >\n            {note ?? summary}\n          </motion.p>\n        </AnimatePresence>\n      </div>\n\n      <div className=\"mt-3 flex items-center justify-between gap-3 border-zinc-100 border-t px-1 pt-3 dark:border-zinc-900\">\n        <div className=\"min-w-0 leading-tight\">\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">Total</p>\n          <div className=\"relative h-6 overflow-hidden\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.p\n                animate={{ opacity: 1, y: 0 }}\n                className=\"font-semibold text-lg text-zinc-900 tabular-nums tracking-tight dark:text-zinc-50\"\n                exit={{ opacity: 0, y: -10 }}\n                initial={{ opacity: 0, y: 10 }}\n                key={total}\n                transition={fade}\n              >\n                {total ? money(total) : \"—\"}\n              </motion.p>\n            </AnimatePresence>\n          </div>\n        </div>\n        <button\n          aria-disabled={!total}\n          className={cn(\n            \"h-10 shrink-0 rounded-full px-5 font-medium text-sm outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n            reserved &&\n              \"bg-emerald-600 text-white dark:bg-emerald-500 dark:text-emerald-950\",\n            !reserved &&\n              (total\n                ? \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n                : \"bg-zinc-100 text-zinc-400 dark:bg-zinc-900 dark:text-zinc-600\")\n          )}\n          onClick={() => {\n            if (start === null || end === null) {\n              return;\n            }\n            onReserve?.({ checkIn: isoOf(start), checkOut: isoOf(end), total });\n            setReserved(true);\n          }}\n          type=\"button\"\n        >\n          {reserved ? \"Reserved\" : \"Reserve\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/stay-picker.tsx",
      "target": "components/kokonutui/stay-picker.tsx"
    }
  ]
}