{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"booking","type":"registry:block","title":"Booking","description":"Book a call on one card: the time you pick prints onto the host’s ticket, then the times turn into your details and the confirmation.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/input.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/textarea.json?as=dependency"],"files":[{"path":"registry/booking.tsx","type":"registry:component","content":"// Hairline UI · booking · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUpRight, CalendarPlus, ChevronLeft, ChevronRight, Clock, Video } from \"lucide-react\";\nimport { Faces, FACES_MORPH } from \"./faces\";\nimport { Input } from \"./input\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { Textarea } from \"./textarea\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * BOOKING: a time you pick prints onto your ticket\n *\n *   pick      the host's ticket is the lit surface, a stub punched\n *             off with two notches and a perforation. A week of\n *             days and its free times sit beside it; the time you\n *             pick prints onto the stub, its digits rolling the\n *             way the time moved, the date morphing\n *   details   the times turn into your name, email and a note on\n *             the same surface; Book goes Booking → Booked\n *   booked    a check stamps itself onto the stub, the stub reads\n *             Confirmed, and the surface offers the calendar file,\n *             the meeting link and Cancel\n *\n * The calendar is yours: getSlots gives the free start times of\n * the week on screen (once per week, aborted when you move on),\n * onBook makes the booking and onCancel undoes it. Throw and the\n * block says why where it happened.\n * ───────────────────────────────────────────────────────── */\n\nexport interface BookingHost {\n  name: string;\n  /** A line under the name, e.g. \"Founder, Relay\". */\n  role?: string;\n  /** Photo URL; initials when missing. */\n  src?: string;\n}\n\nexport interface BookingRequest {\n  start: Date;\n  end: Date;\n  /** The IANA zone the times were shown in. */\n  timeZone: string;\n  name: string;\n  email: string;\n  note: string;\n}\n\nexport interface BookingConfirmation {\n  /** Your booking's id, passed back to onCancel. */\n  id?: string;\n  /** A video call link, shown once booked. */\n  meetingUrl?: string;\n}\n\nexport type BookedCall = BookingRequest & BookingConfirmation;\n\nexport interface BookingProps {\n  host: BookingHost;\n  /** What's being booked, e.g. \"Intro call\". */\n  title: string;\n  description?: string;\n  /** Minutes. */\n  duration: number;\n  /** Where it happens, e.g. \"Google Meet\". */\n  location?: string;\n  /** Free start times from `from` up to `to` (one week). Called once per week shown; abort when signal fires. */\n  getSlots: (range: { from: Date; to: Date; timeZone: string }, signal: AbortSignal) => Promise<Array<Date | string | number>>;\n  /** Make the booking; resolve with its id and meeting link, throw an Error to show its message. */\n  onBook: (request: BookingRequest) => Promise<BookingConfirmation | void>;\n  /** Cancel a booking made here; shows Cancel booking once booked. */\n  onCancel?: (booking: BookedCall) => Promise<unknown>;\n  /** IANA zone to show times in; defaults to the visitor's. */\n  timeZone?: string;\n  locale?: string;\n  /** 12-hour clock; defaults to the locale's. */\n  hour12?: boolean;\n  /** 0 is Sunday, 1 is Monday. */\n  weekStartsOn?: 0 | 1;\n  /** A day in the first week shown; defaults to today. */\n  defaultDate?: Date | string | number;\n  className?: string;\n}\n\ntype Day = { y: number; m: number; d: number };\ntype Step = \"pick\" | \"details\" | \"done\";\ntype Week = { status: \"ready\" | \"error\"; slots: number[] };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst NOTCH = 10;\n\n/* ── Calendar days in a zone, without a date library ── */\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst keyOf = (d: Day) => `${d.y}-${pad(d.m)}-${pad(d.d)}`;\nconst noon = (d: Day) => new Date(Date.UTC(d.y, d.m - 1, d.d, 12));\nconst addDays = (d: Day, n: number): Day => {\n  const t = new Date(Date.UTC(d.y, d.m - 1, d.d + n));\n  return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() };\n};\nconst startOfWeek = (d: Day, first: number) => addDays(d, -((noon(d).getUTCDay() - first + 7) % 7));\n\nconst clocks = new Map<string, Intl.DateTimeFormat>();\nfunction wallClock(at: number, timeZone: string) {\n  let f = clocks.get(timeZone);\n  if (!f) {\n    f = new Intl.DateTimeFormat(\"en-US\", { timeZone, hourCycle: \"h23\", year: \"numeric\", month: \"numeric\", day: \"numeric\", hour: \"numeric\", minute: \"numeric\" });\n    clocks.set(timeZone, f);\n  }\n  const p = Object.fromEntries(f.formatToParts(at).map((x) => [x.type, Number(x.value)]));\n  return { y: p.year, m: p.month, d: p.day, h: p.hour % 24, min: p.minute };\n}\nconst dayIn = (at: number, timeZone: string): Day => {\n  const { y, m, d } = wallClock(at, timeZone);\n  return { y, m, d };\n};\n/** The instant the clock in `timeZone` reads midnight on `day` (two passes settle a clock change). */\nfunction midnight(day: Day, timeZone: string) {\n  const target = Date.UTC(day.y, day.m - 1, day.d);\n  let t = target;\n  for (let i = 0; i < 2; i++) {\n    const w = wallClock(t, timeZone);\n    t = target - (Date.UTC(w.y, w.m - 1, w.d, w.h, w.min) - t);\n  }\n  return new Date(t);\n}\n\n/* ── The calendar file and link, from the booking itself ── */\nconst stamp = (d: Date) => d.toISOString().replace(/[-:]/g, \"\").replace(/\\.\\d{3}/, \"\");\nconst icsText = (s: string) => s.replace(/[\\\\;,]/g, (c) => `\\\\${c}`).replace(/\\n/g, \"\\\\n\");\nfunction calendarFile(b: BookedCall, title: string, host: string, location: string | undefined, made: Date) {\n  const lines = [\n    \"BEGIN:VCALENDAR\",\n    \"VERSION:2.0\",\n    \"PRODID:-//Booking//EN\",\n    \"BEGIN:VEVENT\",\n    `UID:${b.id ?? stamp(b.start)}-${b.email}`,\n    `DTSTAMP:${stamp(made)}`,\n    `DTSTART:${stamp(b.start)}`,\n    `DTEND:${stamp(b.end)}`,\n    `SUMMARY:${icsText(`${title} with ${host}`)}`,\n    ...(b.meetingUrl || location ? [`LOCATION:${icsText(b.meetingUrl ?? location ?? \"\")}`] : []),\n    ...(b.note ? [`DESCRIPTION:${icsText(b.note)}`] : []),\n    \"END:VEVENT\",\n    \"END:VCALENDAR\",\n  ];\n  return `data:text/calendar;charset=utf-8,${encodeURIComponent(lines.join(\"\\r\\n\"))}`;\n}\nfunction googleLink(b: BookedCall, title: string, host: string, location: string | undefined) {\n  const q = new URLSearchParams({ action: \"TEMPLATE\", text: `${title} with ${host}`, dates: `${stamp(b.start)}/${stamp(b.end)}` });\n  if (b.meetingUrl || location) q.set(\"location\", b.meetingUrl ?? location ?? \"\");\n  if (b.note) q.set(\"details\", b.note);\n  return `https://calendar.google.com/calendar/render?${q}`;\n}\n\n/* ── The lit ticket: --primary lit from its top corner by --primary-foreground, a deeper lower edge, 1px light edges ── */\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst NOTCHES = `radial-gradient(circle at 0 var(--cut, 60%), transparent ${NOTCH - 1}px, #000 ${NOTCH - 0.5}px), radial-gradient(circle at 100% var(--cut, 60%), transparent ${NOTCH - 1}px, #000 ${NOTCH - 0.5}px)`;\nconst TICKET: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(130% 80% at 0% 0%, ${mix(\"var(--primary-foreground)\", 24)} 0%, transparent 55%), linear-gradient(170deg, transparent 40%, ${mix(\"black\", 24)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 38)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n  // Two notches punched from the edges at the perforation (--cut is measured), one half of the mask each.\n  maskImage: NOTCHES,\n  WebkitMaskImage: NOTCHES,\n  maskSize: \"51% 100%\",\n  WebkitMaskSize: \"51% 100%\",\n  maskPosition: \"left, right\",\n  WebkitMaskPosition: \"left, right\",\n  maskRepeat: \"no-repeat\",\n  WebkitMaskRepeat: \"no-repeat\",\n};\nconst PERFORATION: React.CSSProperties = { backgroundImage: `repeating-linear-gradient(90deg, ${mix(\"var(--primary-foreground)\", 30)} 0 5px, transparent 5px 10px)` };\nconst NOTCH_EDGE: React.CSSProperties = { width: NOTCH * 2, height: NOTCH * 2, top: 0, boxShadow: `inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 22)}` };\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\nconst LIT: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, ${mix(\"var(--primary-foreground)\", 16)}, transparent 60%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 32)}, inset 0 0 0 1px ${mix(\"black\", 10)}`,\n};\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n\n/** The time on the stub: hours and minutes are odometers that roll the way the time moved. */\nfunction PrintedTime({ at, format, locale }: { at: number; format: Intl.DateTimeFormat; locale?: string }) {\n  return (\n    <>\n      {format.formatToParts(at).map((p, i) => {\n        const n = Number(p.value);\n        if ((p.type === \"hour\" || p.type === \"minute\") && !Number.isNaN(n)) {\n          return <NumberRoll key={p.type} value={n} locales={locale} format={{ minimumIntegerDigits: p.value.length, useGrouping: false }} duration={900} />;\n        }\n        if (p.type === \"dayPeriod\") {\n          return (\n            <span key=\"period\" className=\"ml-[0.12em] text-[0.42em] font-medium tracking-[-0.01em]\">\n              <TextMorph>{p.value}</TextMorph>\n            </span>\n          );\n        }\n        return <span key={i}>{p.value}</span>;\n      })}\n    </>\n  );\n}\n\n/** A check drawn onto the stub once the time is booked. */\nfunction Stamp({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-10 shrink-0 items-center justify-center rounded-full\"\n      style={{\n        boxShadow: `inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 40)}`,\n        background: mix(\"var(--primary-foreground)\", 10),\n        opacity: on ? 1 : 0,\n        transform: on || reduced ? \"none\" : \"scale(0.6) rotate(-12deg)\",\n        filter: on || reduced ? \"none\" : \"blur(3px)\",\n        transition: reduced\n          ? \"opacity 160ms\"\n          : on\n            ? `opacity 280ms ${EASE} 160ms, transform 520ms ${EASE_THROW} 160ms, filter 280ms ${EASE} 160ms`\n            : \"opacity 160ms ease-out, transform 200ms ease-out, filter 160ms ease-out\",\n      }}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-[18px]\">\n        <path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.8\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          pathLength={1}\n          strokeDasharray={1}\n          style={{ strokeDashoffset: on ? 0 : 1, transition: on && !reduced ? `stroke-dashoffset 460ms ${EASE} 420ms` : \"none\" }}\n        />\n      </svg>\n    </span>\n  );\n}\n\nconst ENTER = (dir: number): Keyframe[] => [\n  { opacity: 0, transform: `translateX(${dir * 22}px)`, filter: \"blur(4px)\" },\n  { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n];\n\nexport function Booking({\n  host,\n  title,\n  description,\n  duration,\n  location,\n  getSlots,\n  onBook,\n  onCancel,\n  timeZone: zoneProp,\n  locale,\n  hour12,\n  weekStartsOn = 1,\n  defaultDate,\n  className = \"\",\n}: BookingProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n\n  // Today and the visitor's zone exist only in the browser, so the calendar starts once mounted.\n  const [ready, setReady] = React.useState(false);\n  const [localZone, setLocalZone] = React.useState<string | null>(null);\n  const [today, setToday] = React.useState<Day | null>(null);\n  const [week, setWeek] = React.useState<Day | null>(null);\n  const zone = zoneProp ?? localZone;\n\n  const [weeks, setWeeks] = React.useState<Record<string, Week>>({});\n  const [dayPick, setDayPick] = React.useState<string | null>(null);\n  const [slot, setSlot] = React.useState<number | null>(null);\n  const [step, setStep] = React.useState<Step>(\"pick\");\n  const [name, setName] = React.useState(\"\");\n  const [email, setEmail] = React.useState(\"\");\n  const [note, setNote] = React.useState(\"\");\n  const [invalid, setInvalid] = React.useState<{ name?: string; email?: string }>({});\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [booked, setBooked] = React.useState<{ call: BookedCall; made: Date } | null>(null);\n  const [cancel, setCancel] = React.useState<{ status: ActionStatus; confirm: boolean; error?: string }>({ status: \"idle\", confirm: false });\n  const [said, setSaid] = React.useState(\"\");\n\n  const getSlotsRef = React.useRef(getSlots);\n  React.useLayoutEffect(() => {\n    getSlotsRef.current = getSlots;\n  });\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => {\n      const z = zoneProp ?? Intl.DateTimeFormat().resolvedOptions().timeZone;\n      const now = dayIn(Date.now(), z);\n      setLocalZone(Intl.DateTimeFormat().resolvedOptions().timeZone);\n      setToday(now);\n      setWeek(startOfWeek(defaultDate !== undefined ? dayIn(new Date(defaultDate).getTime(), z) : now, weekStartsOn));\n      setReady(true);\n    });\n    return () => cancelAnimationFrame(frame);\n    // The first week is chosen once; after that the visitor moves it.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const weekKey = week && zone ? `${zone}|${keyOf(week)}` : null;\n  const entry = weekKey ? weeks[weekKey] : undefined;\n  const loading = Boolean(weekKey) && !entry;\n\n  // One request per week shown, aborted when you move on before it answers.\n  React.useEffect(() => {\n    if (!weekKey || !week || !zone || !loading) return;\n    const controller = new AbortController();\n    const from = midnight(week, zone);\n    const to = midnight(addDays(week, 7), zone);\n    getSlotsRef\n      .current({ from, to, timeZone: zone }, controller.signal)\n      .then((list) => {\n        if (controller.signal.aborted) return;\n        const slots = [...new Set(list.map((s) => new Date(s).getTime()))].filter((t) => t >= from.getTime() && t < to.getTime()).sort((a, b) => a - b);\n        setWeeks((p) => ({ ...p, [weekKey]: { status: \"ready\", slots } }));\n      })\n      .catch(() => {\n        if (controller.signal.aborted) return;\n        setWeeks((p) => ({ ...p, [weekKey]: { status: \"error\", slots: [] } }));\n      });\n    return () => controller.abort();\n  }, [weekKey, week, zone, loading]);\n\n  const fmt = React.useMemo(() => {\n    const tz = zone ?? undefined;\n    const time = new Intl.DateTimeFormat(locale, { timeZone: tz, hour: \"numeric\", minute: \"2-digit\", hour12 });\n    const date = new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", weekday: \"long\", day: \"numeric\", month: \"long\" });\n    const short = new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", weekday: \"short\", day: \"numeric\", month: \"short\" });\n    const weekday = new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", weekday: \"short\" });\n    const month = new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", month: \"long\", year: \"numeric\" });\n    const offset = tz ? new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: \"shortOffset\" }) : null;\n    return { time, date, short, weekday, month, offset };\n  }, [locale, zone, hour12]);\n\n  const days = React.useMemo(() => (week ? Array.from({ length: 7 }, (_, i) => addDays(week, i)) : []), [week]);\n  const byDay = React.useMemo(() => {\n    const map = new Map<string, number[]>();\n    if (!zone || !entry) return map;\n    for (const t of entry.slots) {\n      const k = keyOf(dayIn(t, zone));\n      map.set(k, [...(map.get(k) ?? []), t]);\n    }\n    return map;\n  }, [entry, zone]);\n\n  const todayKey = today ? keyOf(today) : \"\";\n  const dayKey = (dayPick && byDay.has(dayPick) && days.some((d) => keyOf(d) === dayPick) ? dayPick : days.map(keyOf).find((k) => byDay.has(k))) ?? null;\n  const day = days.find((d) => keyOf(d) === dayKey) ?? null;\n  const daySlots = dayKey ? (byDay.get(dayKey) ?? []) : [];\n  const picked = slot !== null && daySlots.includes(slot) ? slot : null;\n  const shownAt = step === \"done\" && booked ? booked.call.start.getTime() : picked;\n  const canPrev = Boolean(week && today && keyOf(week) > keyOf(startOfWeek(today, weekStartsOn)));\n  const busy = status === \"pending\" || status === \"success\";\n\n  // The stub keeps the last time it printed while it fades, so the next pick rolls from it.\n  const [printed, setPrinted] = React.useState<number | null>(null);\n  if (shownAt !== null && shownAt !== printed) setPrinted(shownAt);\n\n  const offsetAt = shownAt ?? (week && zone ? midnight(week, zone).getTime() : 0);\n  const zoneName = zone ? `${zone.split(\"/\").pop()?.replace(/_/g, \" \")} time` : \"\";\n  const zoneOffset = fmt.offset?.formatToParts(offsetAt).find((p) => p.type === \"timeZoneName\")?.value;\n  const zoneLabel = zoneOffset ? `${zoneName} · ${zoneOffset}` : zoneName;\n  const shownDay = shownAt !== null && zone ? dayIn(shownAt, zone) : day;\n  const dateLabel = shownDay ? fmt.date.format(noon(shownDay)) : \"Choose a day\";\n\n  /* ── Motion that marks a change: the week and the day's times come in from the side you moved to ── */\n  const stripRef = React.useRef<HTMLDivElement>(null);\n  const gridRef = React.useRef<HTMLDivElement>(null);\n  const ringRef = React.useRef<HTMLSpanElement>(null);\n  const ringShown = React.useRef(false);\n  const lastWeek = React.useRef<string | null>(null);\n  const lastDay = React.useRef<string | null>(null);\n  const ticketRef = React.useRef<HTMLDivElement>(null);\n  const perfRef = React.useRef<HTMLDivElement>(null);\n  const faceRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n\n  React.useLayoutEffect(() => {\n    const prev = lastWeek.current;\n    lastWeek.current = weekKey;\n    if (!prev || !weekKey || prev === weekKey || reduced) return;\n    stripRef.current?.animate(ENTER(weekKey > prev ? 1 : -1), { duration: 420, easing: EASE });\n  }, [weekKey, reduced]);\n\n  React.useLayoutEffect(() => {\n    const prev = lastDay.current;\n    lastDay.current = dayKey;\n    if (!prev || !dayKey || prev === dayKey || reduced) return;\n    gridRef.current?.animate(ENTER(dayKey > prev ? 1 : -1), { duration: 380, easing: EASE });\n  }, [dayKey, reduced]);\n\n  // The ring on the picked time is thrown between times; it's written to the element, not to state.\n  React.useLayoutEffect(() => {\n    const ring = ringRef.current;\n    const grid = gridRef.current;\n    if (!ring) return;\n    const place = () => {\n      const btn = picked !== null ? grid?.querySelector<HTMLElement>(`[data-slot=\"${picked}\"]`) : null;\n      if (!btn) {\n        ring.style.opacity = \"0\";\n        ringShown.current = false;\n        return;\n      }\n      const glide = ringShown.current && !reduced;\n      ring.style.transition = glide ? `transform 440ms ${EASE_THROW}, width 380ms ${EASE}, opacity 200ms` : \"opacity 200ms\";\n      ring.style.width = `${btn.offsetWidth}px`;\n      ring.style.height = `${btn.offsetHeight}px`;\n      ring.style.transform = `translate(${btn.offsetLeft}px, ${btn.offsetTop}px)`;\n      ring.style.opacity = \"1\";\n      ringShown.current = true;\n    };\n    place();\n    if (!grid) return;\n    const observer = new ResizeObserver(place);\n    observer.observe(grid);\n    return () => observer.disconnect();\n  }, [picked, dayKey, reduced, loading]);\n\n  // The notches sit wherever the perforation is, in either layout.\n  React.useLayoutEffect(() => {\n    const ticket = ticketRef.current;\n    const perf = perfRef.current;\n    if (!ticket || !perf) return;\n    const place = () => ticket.style.setProperty(\"--cut\", `${perf.offsetTop + perf.offsetHeight / 2}px`);\n    place();\n    const observer = new ResizeObserver(place);\n    observer.observe(ticket);\n    return () => observer.disconnect();\n  }, []);\n\n  // Focus follows the surface to its next face.\n  const faceIndex = step === \"pick\" ? 0 : step === \"details\" ? 1 : 2;\n  const moved = React.useRef(false);\n  React.useEffect(() => {\n    if (!ready) return;\n    if (!moved.current) {\n      moved.current = true;\n      return;\n    }\n    faceRefs.current[faceIndex]?.focus({ preventScroll: true });\n  }, [faceIndex, ready]);\n\n  const goWeek = (dir: number) => {\n    if (!week || (dir < 0 && !canPrev)) return;\n    setWeek(addDays(week, dir * 7));\n    setSlot(null);\n  };\n\n  const pickDay = (k: string) => {\n    if (k === dayKey) return;\n    setDayPick(k);\n    setSlot(null);\n  };\n\n  const timeShort = (t: number) => fmt.time.format(t);\n  const refetch = (key: string | null) => {\n    if (!key) return;\n    setWeeks((p) => {\n      const next = { ...p };\n      delete next[key];\n      return next;\n    });\n  };\n\n  const book = async () => {\n    if (picked === null || !zone || busy) return;\n    const problems = {\n      name: name.trim() ? undefined : \"Add your name\",\n      email: /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email.trim()) ? undefined : email.trim() ? \"That email doesn’t look right\" : \"Add your email\",\n    };\n    setInvalid(problems);\n    if (problems.name || problems.email) return;\n    const request: BookingRequest = { start: new Date(picked), end: new Date(picked + duration * 60_000), timeZone: zone, name: name.trim(), email: email.trim(), note: note.trim() };\n    setStatus(\"pending\");\n    setError(null);\n    try {\n      const confirmation = (await onBook(request)) ?? {};\n      setStatus(\"success\");\n      setBooked({ call: { ...request, ...confirmation }, made: new Date() });\n      setSaid(`Booked ${fmt.date.format(noon(dayIn(picked, zone)))} at ${timeShort(picked)}`);\n      window.setTimeout(() => setStep(\"done\"), reduced ? 500 : 700);\n    } catch (e) {\n      setStatus(\"error\");\n      setError(e instanceof Error && e.message ? e.message : \"Couldn’t book that time. Try again.\");\n      // The week may have changed under you (someone took the time): ask for it again.\n      refetch(weekKey);\n    }\n  };\n\n  const back = () => {\n    if (busy) return;\n    setStep(\"pick\");\n    setStatus(\"idle\");\n    setError(null);\n    setInvalid({});\n  };\n\n  const cancelBooking = async () => {\n    if (!booked || !onCancel || cancel.status === \"pending\" || cancel.status === \"success\") return;\n    if (!cancel.confirm) {\n      setCancel({ status: \"idle\", confirm: true });\n      return;\n    }\n    setCancel({ status: \"pending\", confirm: true });\n    try {\n      await onCancel(booked.call);\n      setCancel({ status: \"success\", confirm: true });\n      setSaid(\"Booking cancelled\");\n      window.setTimeout(\n        () => {\n          setStep(\"pick\");\n          setStatus(\"idle\");\n          setSlot(null);\n          setCancel({ status: \"idle\", confirm: false });\n          refetch(weekKey);\n          window.setTimeout(() => setBooked(null), FACES_MORPH);\n        },\n        reduced ? 500 : 800,\n      );\n    } catch (e) {\n      setCancel({ status: \"error\", confirm: true, error: e instanceof Error && e.message ? e.message : \"Couldn’t cancel. Try again.\" });\n    }\n  };\n\n  // Asked to confirm a cancel, then left alone: the button goes back to how it was.\n  React.useEffect(() => {\n    if (!cancel.confirm || cancel.status !== \"idle\") return;\n    const timer = window.setTimeout(() => setCancel({ status: \"idle\", confirm: false }), 4000);\n    return () => window.clearTimeout(timer);\n  }, [cancel]);\n\n  const roving = (e: React.KeyboardEvent<HTMLElement>, keys: string[], current: string | null, select: (k: string) => void, cols = 1) => {\n    const i = Math.max(0, current ? keys.indexOf(current) : 0);\n    const moves: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols };\n    let next = -1;\n    if (e.key in moves) next = Math.min(keys.length - 1, Math.max(0, i + moves[e.key]));\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = keys.length - 1;\n    if (next < 0) return;\n    e.preventDefault();\n    select(keys[next]);\n    e.currentTarget.querySelector<HTMLElement>(`[data-key=\"${keys[next]}\"]`)?.focus();\n  };\n\n  const icon = `flex size-8 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const big = `inline-flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-full text-[15px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const quiet = `cursor-pointer rounded-full text-[13.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const done = step === \"done\";\n  const call = booked?.call;\n  const availableKeys = days.map(keyOf).filter((k) => byDay.has(k));\n  const dayIndex = days.findIndex((d) => keyOf(d) === dayKey);\n\n  return (\n    <section\n      aria-label={`Book ${title}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && step === \"details\") {\n          e.preventDefault();\n          back();\n        }\n      }}\n      className={`w-full max-w-[820px] rounded-[32px] p-2.5 text-card-foreground ${className}`}\n      style={CARD}\n    >\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 820px)\" }} />\n      <div className=\"@container w-full\">\n        <div className=\"grid grid-cols-1 gap-2.5 @[660px]:grid-cols-[minmax(0,300px)_minmax(0,1fr)]\">\n          {/* The one lit surface: the host's ticket, with a stub the time prints onto. */}\n          <div ref={ticketRef} className=\"relative flex flex-col overflow-clip rounded-[24px] text-primary-foreground\" style={TICKET}>\n            <div className=\"flex flex-1 flex-col p-5\">\n              <div className=\"flex items-center gap-3\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"relative flex size-10 shrink-0 select-none items-center justify-center overflow-hidden rounded-full text-[14px] font-medium\"\n                  style={{ background: mix(\"var(--primary-foreground)\", 16), boxShadow: `inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 22)}` }}\n                >\n                  {initials(host.name)}\n                  {host.src && (\n                    // eslint-disable-next-line @next/next/no-img-element\n                    <img src={host.src} alt=\"\" decoding=\"async\" className=\"absolute inset-0 size-full object-cover\" />\n                  )}\n                </span>\n                <span className=\"min-w-0\">\n                  <span className=\"block truncate text-[14.5px] font-medium tracking-[-0.01em]\">{host.name}</span>\n                  {host.role && <span className=\"block truncate text-[12.5px] text-primary-foreground/70\">{host.role}</span>}\n                </span>\n              </div>\n              <h2 className=\"mt-4 text-[22px] font-medium leading-tight tracking-[-0.03em] @[480px]:mt-5 @[480px]:text-[24px] @[660px]:mt-7 @[660px]:text-[28px]\">{title}</h2>\n              {description && <p className=\"mt-2 hidden text-[13.5px] leading-relaxed text-primary-foreground/75 @[480px]:block\">{description}</p>}\n              <p className=\"mt-3 flex flex-wrap items-center gap-x-4 @[480px]:mt-4 gap-y-1.5 text-[13px] text-primary-foreground/85\">\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <Clock aria-hidden=\"true\" className=\"size-[15px] opacity-80\" />\n                  {duration} min\n                </span>\n                {location && (\n                  <span className=\"inline-flex items-center gap-1.5\">\n                    <Video aria-hidden=\"true\" className=\"size-[15px] opacity-80\" />\n                    {location}\n                  </span>\n                )}\n              </p>\n            </div>\n\n            {/* The perforation, with the edges of the notches the mask punches at either end. */}\n            <div ref={perfRef} aria-hidden=\"true\" className=\"relative h-5\">\n              <span className=\"absolute rounded-full\" style={{ ...NOTCH_EDGE, left: -NOTCH }} />\n              <span className=\"absolute rounded-full\" style={{ ...NOTCH_EDGE, right: -NOTCH }} />\n              <span className=\"absolute inset-x-[18px] top-1/2 h-px\" style={PERFORATION} />\n            </div>\n\n            <div className=\"relative px-5 pb-5 pt-3\">\n              <div className=\"min-w-0\">\n                <p className=\"text-[12.5px] text-primary-foreground/65\">\n                  <TextMorph>{done ? \"Confirmed\" : picked !== null ? \"Your time\" : \"Pick a time\"}</TextMorph>\n                </p>\n                <p className=\"mt-1 truncate pr-12 text-[15px] font-medium tracking-[-0.01em]\">\n                  <TextMorph>{dateLabel}</TextMorph>\n                </p>\n                <p className=\"mt-3 grid text-[clamp(40px,13cqw,52px)] font-medium leading-none tracking-[-0.045em] tabular-nums @[660px]:text-[52px]\">\n                  <span className=\"sr-only\">{shownAt !== null ? `${dateLabel}, ${timeShort(shownAt)}` : \"No time picked yet\"}</span>\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"whitespace-nowrap [grid-area:1/1]\"\n                    style={{ opacity: shownAt !== null ? 1 : 0, filter: shownAt !== null || reduced ? \"none\" : \"blur(6px)\", transition: reduced ? \"opacity 160ms\" : `opacity 280ms ${EASE}, filter 280ms ${EASE}` }}\n                  >\n                    {printed !== null ? <PrintedTime at={printed} format={fmt.time} locale={locale} /> : null}\n                  </span>\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"whitespace-nowrap font-extralight text-primary-foreground/40 [grid-area:1/1]\"\n                    style={{ opacity: shownAt !== null ? 0 : 1, transition: reduced ? \"opacity 160ms\" : `opacity 220ms ${EASE}` }}\n                  >\n                    ––:––\n                  </span>\n                </p>\n                <p className=\"mt-2.5 truncate text-[12.5px] text-primary-foreground/70\">\n                  {shownAt !== null ? `Until ${timeShort(shownAt + duration * 60_000)} · ` : \"\"}\n                  {zoneLabel}\n                </p>\n              </div>\n              <span className=\"absolute right-5 top-3\">\n                <Stamp on={done} reduced={reduced} />\n              </span>\n            </div>\n          </div>\n\n          {/* The flat surface that turns from times to details to the confirmation. */}\n          <Faces active={faceIndex} reduced={reduced} ready={ready}>\n            <div\n              ref={(el) => {\n                faceRefs.current[0] = el;\n              }}\n              tabIndex={-1}\n              role=\"group\"\n              aria-label=\"Pick a time\"\n              className=\"flex flex-col px-2 pb-1.5 pt-2 outline-none @[660px]:px-3 @[660px]:pt-3\"\n            >\n              <div className=\"flex items-center justify-between gap-3 pl-1\">\n                <h3 className=\"text-[15px] font-medium tracking-[-0.01em] text-foreground\">\n                  <TextMorph>{day ? fmt.month.format(noon(day)) : week ? fmt.month.format(noon(week)) : \"Times\"}</TextMorph>\n                </h3>\n                <div className=\"-mr-1 flex items-center\">\n                  <button type=\"button\" aria-label=\"Previous week\" disabled={!canPrev} onClick={() => goWeek(-1)} className={icon}>\n                    <ChevronLeft aria-hidden=\"true\" className=\"size-[18px]\" />\n                  </button>\n                  <button type=\"button\" aria-label=\"Next week\" disabled={!week} onClick={() => goWeek(1)} className={icon}>\n                    <ChevronRight aria-hidden=\"true\" className=\"size-[18px]\" />\n                  </button>\n                </div>\n              </div>\n\n              <div className=\"relative mt-3\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute left-0 top-0 h-full rounded-[16px] bg-primary/[0.08] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--primary)_32%,transparent)]\"\n                  style={{\n                    width: `${100 / 7}%`,\n                    opacity: day ? 1 : 0,\n                    transform: `translateX(${Math.max(0, dayIndex) * 100}%)`,\n                    transition: reduced || !ready ? \"opacity 160ms\" : `transform 460ms ${EASE_THROW}, opacity 200ms`,\n                  }}\n                />\n                <div ref={stripRef} role=\"radiogroup\" aria-label=\"Day\" className=\"relative grid grid-cols-7\" onKeyDown={(e) => roving(e, availableKeys, dayKey, pickDay)}>\n                  {(days.length ? days : Array.from({ length: 7 }, () => null)).map((d, i) => {\n                    if (!d) return <span key={i} className=\"h-[62px]\" />;\n                    const k = keyOf(d);\n                    const count = byDay.get(k)?.length ?? 0;\n                    const on = k === dayKey;\n                    const off = !loading && count === 0;\n                    return (\n                      <button\n                        key={k}\n                        type=\"button\"\n                        role=\"radio\"\n                        data-key={k}\n                        aria-checked={on}\n                        aria-label={`${fmt.date.format(noon(d))}, ${loading ? \"loading\" : count ? `${count} times` : \"no times\"}`}\n                        disabled={off || loading}\n                        tabIndex={on || (!dayKey && i === 0) ? 0 : -1}\n                        onClick={() => pickDay(k)}\n                        className={`relative flex h-[62px] cursor-pointer flex-col items-center justify-center gap-1 rounded-[16px] disabled:cursor-default ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${on ? \"text-primary\" : off ? \"text-muted-foreground/45\" : \"text-foreground hover:bg-accent\"}`}\n                      >\n                        <span className={`text-[11.5px] ${on ? \"text-primary/80\" : \"text-muted-foreground\"} ${off ? \"opacity-60\" : \"\"}`}>{fmt.weekday.format(noon(d))}</span>\n                        <span className={`text-[17px] font-medium leading-none tabular-nums ${off ? \"line-through decoration-1\" : \"\"}`}>{d.d}</span>\n                        <span aria-hidden=\"true\" className={`absolute bottom-[7px] size-1 rounded-full ${k === todayKey ? (on ? \"bg-primary\" : \"bg-foreground/40\") : \"bg-transparent\"}`} />\n                      </button>\n                    );\n                  })}\n                </div>\n              </div>\n\n              <div className=\"mt-5 flex items-baseline justify-between gap-3 pl-1\">\n                <p className=\"text-[12.5px] text-muted-foreground\">\n                  <TextMorph>{day ? fmt.short.format(noon(day)) : loading ? \"Finding times\" : \"Times\"}</TextMorph>\n                </p>\n                {day && (\n                  <p className=\"text-[12.5px] text-muted-foreground\">\n                    <NumberRoll value={daySlots.length} duration={600} /> free\n                  </p>\n                )}\n              </div>\n\n              <div className=\"relative mt-2.5 min-h-[136px]\">\n                <span ref={ringRef} aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 z-10 rounded-full opacity-0 shadow-[inset_0_0_0_1.5px_var(--primary)]\" />\n                {loading ? (\n                  <div aria-hidden=\"true\" className=\"grid grid-cols-[repeat(auto-fill,minmax(88px,1fr))] gap-1.5\">\n                    {Array.from({ length: 9 }, (_, i) => (\n                      <span key={i} className=\"h-10 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n                    ))}\n                  </div>\n                ) : entry?.status === \"error\" ? (\n                  <div className=\"flex min-h-[136px] flex-col items-center justify-center gap-2 text-center\">\n                    <p role=\"alert\" className=\"text-[13.5px] text-muted-foreground\">\n                      Couldn’t load the times.\n                    </p>\n                    <button type=\"button\" onClick={() => refetch(weekKey)} className={`h-9 px-4 ${quiet}`}>\n                      Try again\n                    </button>\n                  </div>\n                ) : !day ? (\n                  <div className=\"flex min-h-[136px] flex-col items-center justify-center gap-2 text-center\">\n                    <p className=\"text-[13.5px] text-muted-foreground\">No free times this week.</p>\n                    <button type=\"button\" onClick={() => goWeek(1)} className={`inline-flex h-9 items-center gap-1 pl-4 pr-3 ${quiet}`}>\n                      Next week\n                      <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n                    </button>\n                  </div>\n                ) : (\n                  <div\n                    ref={gridRef}\n                    role=\"radiogroup\"\n                    aria-label={`Times on ${fmt.date.format(noon(day))}`}\n                    className=\"grid grid-cols-[repeat(auto-fill,minmax(88px,1fr))] gap-1.5\"\n                    onKeyDown={(e) => {\n                      const cols = getComputedStyle(e.currentTarget).gridTemplateColumns.split(\" \").length;\n                      roving(e, daySlots.map(String), picked !== null ? String(picked) : null, (k) => setSlot(Number(k)), cols);\n                    }}\n                  >\n                    {daySlots.map((t, i) => {\n                      const on = t === picked;\n                      return (\n                        <button\n                          key={t}\n                          type=\"button\"\n                          role=\"radio\"\n                          data-slot={t}\n                          data-key={String(t)}\n                          aria-checked={on}\n                          tabIndex={on || (picked === null && i === 0) ? 0 : -1}\n                          onClick={() => setSlot(t)}\n                          className={`h-10 cursor-pointer rounded-full text-[14px] tabular-nums shadow-[inset_0_0_0_1px_var(--border)] ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${on ? \"bg-primary/[0.07] font-medium text-primary\" : \"text-foreground hover:bg-accent\"}`}\n                        >\n                          {timeShort(t)}\n                        </button>\n                      );\n                    })}\n                  </div>\n                )}\n              </div>\n\n              <button type=\"button\" disabled={picked === null} onClick={() => setStep(\"details\")} className={`${big} mt-5 bg-primary text-primary-foreground disabled:cursor-default disabled:opacity-45`} style={LIT}>\n                <TextMorph>{picked !== null ? `Continue with ${timeShort(picked)}` : \"Pick a time\"}</TextMorph>\n              </button>\n            </div>\n\n            <div\n              ref={(el) => {\n                faceRefs.current[1] = el;\n              }}\n              tabIndex={-1}\n              role=\"group\"\n              aria-labelledby={`${baseId}-details`}\n              className=\"flex flex-col px-2 pb-1.5 pt-2 outline-none @[660px]:px-3 @[660px]:pt-3\"\n            >\n              <div className=\"flex items-center gap-1.5\">\n                <button type=\"button\" aria-label=\"Back to times\" disabled={busy} onClick={back} className={`-ml-1.5 ${icon}`}>\n                  <ChevronLeft aria-hidden=\"true\" className=\"size-[18px]\" />\n                </button>\n                <h3 id={`${baseId}-details`} className=\"text-[15px] font-medium tracking-[-0.01em] text-foreground\">\n                  Your details\n                </h3>\n              </div>\n              <form\n                noValidate\n                className=\"mt-4 flex flex-col gap-3.5\"\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  void book();\n                }}\n              >\n                <Input\n                  label=\"Name\"\n                  autoComplete=\"name\"\n                  value={name}\n                  disabled={busy}\n                  error={invalid.name}\n                  onValueChange={(v) => {\n                    setName(v);\n                    if (invalid.name) setInvalid((p) => ({ ...p, name: undefined }));\n                  }}\n                />\n                <Input\n                  label=\"Email\"\n                  type=\"email\"\n                  inputMode=\"email\"\n                  autoComplete=\"email\"\n                  value={email}\n                  disabled={busy}\n                  error={invalid.email}\n                  onValueChange={(v) => {\n                    setEmail(v);\n                    if (invalid.email) setInvalid((p) => ({ ...p, email: undefined }));\n                  }}\n                />\n                <Textarea label=\"Anything to share?\" value={note} disabled={busy} maxLength={500} showCount={false} minRows={3} maxRows={6} onValueChange={setNote} placeholder=\"What you’d like to cover\" />\n                <StatusButton\n                  type=\"submit\"\n                  status={status}\n                  labels={{ idle: picked !== null ? `Book ${timeShort(picked)}` : \"Book\", pending: \"Booking\", success: \"Booked\", error: \"Try again\" }}\n                  className=\"mt-1.5 h-12 w-full text-[15px]\"\n                  style={status === \"error\" ? undefined : LIT}\n                />\n              </form>\n              <p role={error ? \"alert\" : undefined} className={`mt-2 min-h-[18px] text-center text-[12.5px] ${error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n                {error ?? \"You’ll get an invite by email.\"}\n              </p>\n            </div>\n\n            <div\n              ref={(el) => {\n                faceRefs.current[2] = el;\n              }}\n              tabIndex={-1}\n              role=\"group\"\n              aria-labelledby={`${baseId}-done`}\n              className=\"flex flex-col px-2 pb-1.5 pt-2 outline-none @[660px]:px-3 @[660px]:pt-3\"\n            >\n              <h3 id={`${baseId}-done`} className=\"pl-1 text-[20px] font-medium tracking-[-0.02em] text-foreground\">\n                You’re booked\n              </h3>\n              <p className=\"mt-1.5 pl-1 text-[13.5px] leading-relaxed text-muted-foreground\">\n                {call ? (\n                  <>\n                    An invite is on its way to <span className=\"text-foreground\">{call.email}</span>.\n                  </>\n                ) : null}\n              </p>\n\n              {call && (\n                <dl className=\"mt-5 divide-y divide-border rounded-[20px] border border-border text-[13.5px]\">\n                  <div className=\"flex justify-between gap-3 px-4 py-3\">\n                    <dt className=\"text-muted-foreground\">When</dt>\n                    <dd className=\"text-right text-foreground\">\n                      {fmt.short.format(noon(dayIn(call.start.getTime(), call.timeZone)))}, {timeShort(call.start.getTime())} – {timeShort(call.end.getTime())}\n                    </dd>\n                  </div>\n                  <div className=\"flex justify-between gap-3 px-4 py-3\">\n                    <dt className=\"text-muted-foreground\">With</dt>\n                    <dd className=\"text-right text-foreground\">{host.name}</dd>\n                  </div>\n                  {(call.meetingUrl || location) && (\n                    <div className=\"flex items-center justify-between gap-3 px-4 py-3\">\n                      <dt className=\"text-muted-foreground\">Where</dt>\n                      <dd className=\"min-w-0 text-right\">\n                        {call.meetingUrl ? (\n                          <a href={call.meetingUrl} target=\"_blank\" rel=\"noreferrer\" className={`inline-flex max-w-full items-center gap-1 rounded-full text-primary underline-offset-4 hover:underline ${INTERACTION_FOCUS}`}>\n                            <span className=\"truncate\">{location ? `Join ${location}` : \"Join the call\"}</span>\n                            <ArrowUpRight aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                          </a>\n                        ) : (\n                          <span className=\"text-foreground\">{location}</span>\n                        )}\n                      </dd>\n                    </div>\n                  )}\n                </dl>\n              )}\n\n              {call && booked && (\n                <div className=\"mt-4 grid gap-2 @[420px]:grid-cols-2\">\n                  <a\n                    href={calendarFile(call, title, host.name, location, booked.made)}\n                    download={`${title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"booking\"}.ics`}\n                    className={`${big} text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent`}\n                  >\n                    <CalendarPlus aria-hidden=\"true\" className=\"size-[18px]\" />\n                    Add to calendar\n                  </a>\n                  <a href={googleLink(call, title, host.name, location)} target=\"_blank\" rel=\"noreferrer\" className={`${big} text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent`}>\n                    Google Calendar\n                    <ArrowUpRight aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n                  </a>\n                </div>\n              )}\n\n              {onCancel && (\n                <div className=\"mt-3 flex flex-col items-center\">\n                  <StatusButton\n                    variant=\"ghost\"\n                    status={cancel.status}\n                    labels={{ idle: cancel.confirm ? \"Yes, cancel it\" : \"Cancel booking\", pending: \"Cancelling\", success: \"Cancelled\", error: \"Try again\" }}\n                    onClick={() => void cancelBooking()}\n                    className={`h-10 px-4 text-[13.5px] ${cancel.confirm && cancel.status === \"idle\" ? \"text-red-600 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400\" : \"\"}`}\n                  />\n                  {cancel.error && (\n                    <p role=\"alert\" className=\"mt-1 text-center text-[12.5px] text-red-600 dark:text-red-400\">\n                      {cancel.error}\n                    </p>\n                  )}\n                </div>\n              )}\n            </div>\n          </Faces>\n        </div>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default Booking;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · booking · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FACES: the states of one surface, in the same place\n *\n * The active face is in flow; the others are laid over it, faded\n * and nudged aside (faces before it to the left, faces after it to\n * the right), so switching reads as the surface turning to its\n * next side. The height eases between the faces' measured heights,\n * so nothing around it jumps. Used by the blocks (the wallet's\n * activity and keypad, a pricing card and its checkout, a booking's\n * times, details and confirmation).\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const FACES_MORPH = 460;\n\nexport function Faces({\n  active,\n  reduced,\n  ready,\n  className = \"\",\n  children,\n}: {\n  active: number;\n  reduced: boolean;\n  /** False until the first paint, so the faces don't animate into place on mount. */\n  ready: boolean;\n  className?: string;\n  children: React.ReactNode[];\n}) {\n  const refs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [heights, setHeights] = React.useState<number[] | null>(null);\n  const count = children.length;\n\n  React.useLayoutEffect(() => {\n    const els = refs.current.slice(0, count);\n    const observer = new ResizeObserver(() => {\n      const next = els.map((el) => el?.offsetHeight ?? 0);\n      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));\n    });\n    els.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [count]);\n\n  const face = (i: number): React.CSSProperties => {\n    const on = i === active;\n    return {\n      opacity: on ? 1 : 0,\n      transform: on || reduced ? \"none\" : `translateX(${i < active ? -18 : 18}px)`,\n      filter: on || reduced ? \"none\" : \"blur(4px)\",\n      transition: !ready\n        ? \"none\"\n        : reduced\n          ? \"opacity 160ms ease-out\"\n          : on\n            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,\n    };\n  };\n\n  return (\n    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : \"none\" }}>\n      {children.map((child, i) => (\n        <div\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          inert={active !== i || undefined}\n          aria-hidden={active !== i || undefined}\n          className={active === i ? \"relative\" : \"pointer-events-none absolute inset-x-0 top-0\"}\n          style={face(i)}\n        >\n          {child}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · booking · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · booking · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/booking. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}