"use client"; /** * BookingCard, from https://motion-brew.com/components/booking-card * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { useGSAP } from "@gsap/react"; import { type ClassValue, clsx } from "clsx"; import gsap from "gsap"; import { ArrowRight, CalendarPlus } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, type Transition, useAnimation, useReducedMotion, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: check const CheckIcon = (() => { interface CheckIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CheckIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, scale: 1, transition: { duration: 0.3, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], scale: [0.5, 1], transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, }; const CheckIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); CheckIcon.displayName = "CheckIcon"; return CheckIcon; })(); // Animated icon: chevron-down const ChevronDownIcon = (() => { interface ChevronDownIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChevronDownIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const DEFAULT_TRANSITION: Transition = { times: [0, 0.4, 1], duration: 0.5, }; const ChevronDownIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ChevronDownIcon.displayName = "ChevronDownIcon"; return ChevronDownIcon; })(); // Animated icon: rotate-ccw const RotateCCWIcon = (() => { interface RotateCCWIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface RotateCCWIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const RotateCCWIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) onMouseEnter?.(e); else controls.start("animate"); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) onMouseLeave?.(e); else controls.start("normal"); }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); RotateCCWIcon.displayName = "RotateCCWIcon"; return RotateCCWIcon; })(); // BookingCard /** * BookingCard * * Trigger: press. A white booking card for a trial class, a demo call or any * slot someone reserves. A strip of upcoming days scrolls sideways; picking a * day glides an ink pill to it on a spring, and the times for that day unfold * below with their own sliding pill. The full-width button wakes up once a * day and a time are chosen: its label rolls to the chosen slot, the arrow * nudges on hover, and a press runs a short spinner before the button turns * green and draws a check. Then the whole card becomes the confirmation: a * green seal spins in, draws its check and bursts sparks (the same seal as * VerifyFlow), the date and time tiles rise in with a clock whose hands sweep * to the booked time, and the add to calendar button flips its icon to a * check. Change goes back to the picker with the slot kept. Days and times * that are taken stay visible but can't be picked. Dates and times are radio groups with arrow keys, and * every change is announced. Reduced motion swaps the glides for fades. * * Engine: motion/react for the sliding pills, the unfolding times, the * button's label, spinner and check, and the confirmation's layout; gsap + * @gsap/react for the confirmation's drawn icons (scoped with useGSAP). */ gsap.registerPlugin(useGSAP); export interface BookingCardProps { /** Card title. Default `"Book a free trial"`. */ title?: string; /** Line under the title. */ subtitle?: string; /** First day in the strip. Default today. */ start?: Date; /** How many days the strip shows. The month menu lists every month they cover. Default `45`. */ days?: number; /** Times offered each day, as labels. Default five slots from 09:00 to 18:00. */ times?: string[]; /** Days that can't be booked. Default Sundays. */ isUnavailable?: (date: Date) => boolean; /** Times taken on a given day. Default a few, varying by day. */ takenTimes?: (date: Date) => string[]; /** Button label before a slot is chosen. Default `"Book a trial"`. */ cta?: string; /** Heading on the confirmation. Default `"You're booked"`. */ doneTitle?: string; /** Line under the confirmation heading. */ doneText?: string; /** Called by the confirmation's add to calendar button. */ onAddToCalendar?: (slot: { date: Date; time: string }) => void; /** Called on press with the chosen slot. Return a promise to hold the spinner until it settles. */ onBook?: (slot: { date: Date; time: string }) => void | Promise; className?: string; } type Phase = "idle" | "loading" | "done"; const PILL: Transition = { type: "spring", stiffness: 420, damping: 34 }; const GLIDE = [0.22, 1, 0.36, 1] as const; const DEFAULT_TIMES = ["09:00", "10:30", "13:00", "15:30", "18:00"]; const SUCCESS = "#269a5a"; const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const fmt = (d: Date, opts: Intl.DateTimeFormatOptions) => d.toLocaleDateString("en-US", opts); /** A couple of taken times per day, stable for a given date. */ function defaultTaken(date: Date, times: string[]) { const seed = date.getDate() * 7 + date.getMonth() * 3; return times.filter( (_, i) => (seed + i * 5) % 7 === 0 || (seed + i) % 5 === 0, ); } /** A label that rolls up out of a blur whenever its text changes. */ function Rolling({ text, still }: { text: string; still: boolean }) { return ( {text} ); } /** Arrow keys move through a radio group, skipping options that can't be picked. */ function roving( e: React.KeyboardEvent, list: T[], current: number, enabled: (item: T) => boolean, pick: (i: number) => void, ) { const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; if (!step) return; e.preventDefault(); for (let i = current + step; i >= 0 && i < list.length; i += step) if (enabled(list[i]!)) return pick(i); } interface Month { key: string; label: string; /** Index of the month's first day in the strip. */ first: number; } /** * A small pill that names the month in view and opens a list of months. The menu grows out of the pill, * a soft highlight follows the pointer, and picking a month glides the strip to its first day. */ function MonthMenu({ months, current, onPick, still, }: { months: Month[]; current: number; onPick: (i: number) => void; still: boolean; }) { const [open, setOpen] = React.useState(false); const [hover, setHover] = React.useState(current); const root = React.useRef(null); const button = React.useRef(null); const options = React.useRef<(HTMLButtonElement | null)[]>([]); const id = React.useId(); React.useEffect(() => { if (!open) return; setHover(current); requestAnimationFrame(() => options.current[current]?.focus()); const away = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away); }, [open, current]); const choose = (i: number, keyboard: boolean) => { onPick(i); setOpen(false); // Only hand focus back for keyboard users, so a mouse pick leaves no ring. if (keyboard) button.current?.focus(); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); button.current?.focus(); return; } const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0; if (!step) return; e.preventDefault(); const next = Math.max(0, Math.min(months.length - 1, hover + step)); setHover(next); options.current[next]?.focus(); }; return (
setOpen((o) => !o)} whileTap={still ? undefined : { scale: 0.96 }} className="flex h-8 cursor-pointer items-center gap-1 rounded-full bg-zinc-100 pl-3 pr-2 text-xs font-medium outline-none transition-colors hover:bg-zinc-200/80 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/6 dark:hover:bg-white/10" > {open && ( setHover(current)} > {months.map((m, i) => ( ))} )}
); } /** Scalloped seal outline, centred in a 100 by 100 box. */ function sealPath(bumps = 9, depth = 0.085, r = 42) { const pts: string[] = []; for (let i = 0; i <= 180; i++) { const t = (i / 180) * Math.PI * 2; const rr = r * (1 + depth * Math.cos(bumps * t)); pts.push( `${(50 + Math.cos(t) * rr).toFixed(2)} ${(50 + Math.sin(t) * rr).toFixed(2)}`, ); } return `M${pts.join(" L")}Z`; } const SEAL = sealPath(); /** Green seal that spins in, draws its check and bursts sparks, then floats. One GSAP timeline on mount, the same seal as VerifyFlow. */ function SuccessSeal({ accent, still }: { accent: string; still: boolean }) { const ref = React.useRef(null); useGSAP( () => { const q = (sel: string) => ref.current!.querySelectorAll(sel); if (still) { gsap.set(q(".seal-tick"), { strokeDashoffset: 0 }); return; } gsap .timeline({ delay: 0.6 }) .from(q(".seal"), { scale: 0, rotation: -60, svgOrigin: "60 60", duration: 0.7, ease: "back.out(1.9)", }) .to( q(".seal-tick"), { strokeDashoffset: 0, duration: 0.35, ease: "power2.out" }, 0.32, ) .fromTo( q(".spark"), { autoAlpha: 1, attr: { "stroke-dashoffset": 1 }, scale: 0.6, svgOrigin: "60 60", }, { attr: { "stroke-dashoffset": 0 }, scale: 1, duration: 0.35, ease: "power3.out", stagger: 0.02, }, 0.28, ) .to( q(".spark"), { autoAlpha: 0, scale: 1.18, svgOrigin: "60 60", duration: 0.45, ease: "power2.in", stagger: 0.02, }, 0.62, ) .fromTo( q(".dot"), { autoAlpha: 1, scale: 0.3, svgOrigin: "60 60" }, { scale: 1.35, autoAlpha: 0, duration: 0.9, ease: "power2.out", stagger: 0.03, }, 0.3, ) .to( q(".seal-wrap"), { y: -3, duration: 1.6, ease: "sine.inOut", repeat: -1, yoyo: true }, 1.1, ); }, { scope: ref }, ); const sparks = Array.from({ length: 8 }, (_, i) => { const a = (i / 8) * Math.PI * 2 - Math.PI / 2 + 0.2; return { x1: 60 + Math.cos(a) * 48, y1: 60 + Math.sin(a) * 48, x2: 60 + Math.cos(a) * 58, y2: 60 + Math.sin(a) * 58, }; }); const dots = Array.from({ length: 6 }, (_, i) => { const a = (i / 6) * Math.PI * 2 + 0.5; return { cx: 60 + Math.cos(a) * 54, cy: 60 + Math.sin(a) * 54 }; }); return ( {sparks.map((p, i) => ( ))} {dots.map((p, i) => ( ))} ); } /** Small clock whose hands sweep round to the booked time. */ function MiniClock({ time, still }: { time: string; still: boolean }) { const root = React.useRef(null); const [h, m] = time.split(":").map(Number) as [number, number]; useGSAP( () => { const hour = ((h % 12) + (m || 0) / 60) * 30; const minute = (m || 0) * 6; const o = { svgOrigin: "12 12", ease: "power3.out", duration: still ? 0 : 1.2, delay: still ? 0 : 1.2, }; gsap.fromTo("[data-hour]", { rotation: 0 }, { rotation: hour, ...o }); gsap.fromTo( "[data-minute]", { rotation: 0 }, { rotation: minute + (still ? 0 : 360), ...o }, ); }, { scope: root, dependencies: [time] }, ); return ( ); } /** Small calendar whose page drops in and whose day dot pops. */ function MiniCalendar({ still }: { still: boolean }) { const root = React.useRef(null); useGSAP( () => { if (still) return; gsap.from("[data-page]", { scaleY: 0, svgOrigin: "12 5", duration: 0.45, delay: 1.1, ease: "back.out(2)", }); gsap.from("[data-dot]", { scale: 0, svgOrigin: "12 15", duration: 0.35, delay: 1.45, ease: "back.out(4)", }); }, { scope: root }, ); return ( ); } const STAGGER = { hidden: {}, shown: { transition: { staggerChildren: 0.07, delayChildren: 0.75 } }, }; const RISE = { hidden: { opacity: 0, y: 12, filter: "blur(4px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.45, ease: GLIDE }, }, }; /** The whole card turns into the confirmation once a booking lands. */ function Confirmation({ date, time, title, text, onChange, onAdd, still, }: { date: Date; time: string; title: string; text: string; onChange: () => void; onAdd?: () => void; still: boolean; }) { const [added, setAdded] = React.useState(false); const focus = React.useRef(null); React.useEffect(() => focus.current?.focus(), []); return ( {title} {text} {[ { icon: , label: "Date", value: fmt(date, { weekday: "short", month: "short", day: "numeric", }), }, { icon: , label: "Time", value: time, }, ].map((d) => ( {d.icon} {d.label} {d.value} ))} Change { if (added) return; setAdded(true); onAdd?.(); }} whileTap={still || added ? undefined : { scale: 0.98 }} animate={{ backgroundColor: added ? SUCCESS : "#242423" }} transition={{ duration: still ? 0 : 0.3, ease: GLIDE }} className="flex h-12 flex-1 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900" > {added ? ( ) : ( )} ); } export const BookingCard = React.forwardRef( function BookingCard( { title = "Book a free trial", subtitle = "Every new member gets one class on us. Pick a date below to get started.", start, days = 45, times = DEFAULT_TIMES, isUnavailable = (d) => d.getDay() === 0, takenTimes, cta = "Book a trial", doneTitle = "You're booked", doneText = "We sent the details to your inbox. See you soon.", onAddToCalendar, onBook, className, }, ref, ) { const still = !!useReducedMotion(); const id = React.useId(); const strip = React.useRef(null); const dayRefs = React.useRef<(HTMLButtonElement | null)[]>([]); const timeRefs = React.useRef<(HTMLButtonElement | null)[]>([]); const dates = React.useMemo(() => { const first = start ? new Date(start) : new Date(); first.setHours(0, 0, 0, 0); return Array.from({ length: Math.max(1, days) }, (_, i) => { const d = new Date(first); d.setDate(first.getDate() + i); return d; }); }, [start?.getTime(), days]); const months = React.useMemo(() => { const list: Month[] = []; dates.forEach((d, i) => { const key = `${d.getFullYear()}-${d.getMonth()}`; if (list[list.length - 1]?.key !== key) list.push({ key, label: fmt(d, { month: "long", year: "numeric" }), first: i, }); }); return list; }, [dates]); const [view, setView] = React.useState(0); // The month pill follows whichever day sits at the left edge of the strip. const onStripScroll = () => { const box = strip.current; if (!box) return; const edge = box.scrollLeft + 24; let i = dayRefs.current.findIndex( (el) => el && el.offsetLeft + el.offsetWidth / 2 >= edge, ); if (i < 0) i = dates.length - 1; const key = `${dates[i]!.getFullYear()}-${dates[i]!.getMonth()}`; const m = months.findIndex((x) => x.key === key); if (m >= 0 && m !== view) setView(m); }; const showMonth = (m: number) => { setView(m); const box = strip.current; const el = dayRefs.current[months[m]!.first]; if (box && el) box.scrollTo({ left: el.offsetLeft - 24, behavior: still ? "auto" : "smooth", }); }; const [day, setDay] = React.useState(null); const [time, setTime] = React.useState(null); const [phase, setPhase] = React.useState("idle"); const taken = React.useMemo( () => (day ? (takenTimes?.(day) ?? defaultTaken(day, times)) : []), [day, takenTimes, times], ); const ready = !!day && !!time; const locked = phase !== "idle"; const pickDay = (i: number, focus = false) => { const d = dates[i]!; if (locked || isUnavailable(d)) return; setDay(d); setTime(null); const el = dayRefs.current[i]; if (focus) el?.focus(); // Center the chosen day in the strip without scrolling the page. const box = strip.current; if (el && box) box.scrollTo({ left: el.offsetLeft - (box.clientWidth - el.offsetWidth) / 2, behavior: still ? "auto" : "smooth", }); }; const pickTime = (i: number, focus = false) => { const t = times[i]!; if (locked || taken.includes(t)) return; setTime(t); if (focus) timeRefs.current[i]?.focus(); }; const book = async () => { if (!ready || locked) return; setPhase("loading"); try { await Promise.all([ onBook?.({ date: day!, time: time! }), new Promise((r) => setTimeout(r, still ? 200 : 900)), ]); setPhase("done"); } catch { setPhase("idle"); } }; // Back to the picker with the slot still chosen, so it can be tweaked. const reset = () => setPhase("idle"); const picker = React.useRef(null); React.useEffect(() => { if (picker.current) picker.current.inert = phase === "done"; }, [phase]); const slot = day ? `${fmt(day, { weekday: "short", month: "short", day: "numeric" })}${time ? `, ${time}` : ""}` : ""; const label = phase === "loading" ? "Booking" : phase === "done" ? "Booked" : ready ? `${cta} for ${slot}` : day ? "Pick a time" : cta; const status = phase === "done" ? `Booked for ${slot}.` : phase === "loading" ? "Booking." : time ? `${slot} selected.` : day ? `${fmt(day, { weekday: "long", month: "long", day: "numeric" })} selected. Pick a time.` : ""; const dayIndex = day ? dates.findIndex((d) => sameDay(d, day)) : -1; const timeIndex = time ? times.indexOf(time) : -1; const firstOpenDay = dates.findIndex((d) => !isUnavailable(d)); const firstOpenTime = times.findIndex((t) => !taken.includes(t)); return (

{title}

{months.length > 0 && ( )}

{subtitle}

{/* Days scroll sideways; the edges fade so the strip reads as endless. */}
{dates.map((d, i) => { const off = isUnavailable(d); const on = i === dayIndex; return ( void (dayRefs.current[i] = el)} type="button" role="radio" aria-checked={on} aria-disabled={off || locked} aria-label={`${fmt(d, { weekday: "long", month: "long", day: "numeric" })}${off ? ", unavailable" : ""}`} tabIndex={ on || (dayIndex < 0 && i === firstOpenDay) ? 0 : -1 } onClick={() => pickDay(i)} onKeyDown={(e) => roving( e, dates, dayIndex < 0 ? i : dayIndex, (x) => !isUnavailable(x), (j) => pickDay(j, true), ) } whileTap={ off || locked || still ? undefined : { scale: 0.94 } } className={cn( "relative flex h-[72px] w-14 shrink-0 snap-start flex-col items-center justify-center rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-zinc-400", off ? "cursor-not-allowed text-zinc-300 dark:text-zinc-600" : "cursor-pointer", !off && !on && "bg-zinc-100 transition-colors hover:bg-zinc-200/80 dark:bg-white/6 dark:hover:bg-white/10", off && "bg-zinc-50 dark:bg-white/[0.03]", )} > {on && ( )} {fmt(d, { weekday: "short" })} {d.getDate()} ); })}
{/* Times unfold once a day is chosen. */} {day && (

Select a time

{times.map((t, i) => { const off = taken.includes(t); const on = t === time; return ( void (timeRefs.current[i] = el)} type="button" role="radio" aria-checked={on} aria-disabled={off || locked} aria-label={`${t}${off ? ", taken" : ""}`} tabIndex={ on || (timeIndex < 0 && i === firstOpenTime) ? 0 : -1 } onClick={() => pickTime(i)} onKeyDown={(e) => roving( e, times, timeIndex < 0 ? i : timeIndex, (x) => !taken.includes(x), (j) => pickTime(j, true), ) } initial={still ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, ease: GLIDE, delay: still ? 0 : 0.06 + i * 0.035, }} whileTap={ off || locked || still ? undefined : { scale: 0.94 } } className={cn( "relative h-10 rounded-full text-sm font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-zinc-400", off ? "cursor-not-allowed bg-zinc-50 text-zinc-300 line-through decoration-1 dark:bg-white/[0.03] dark:text-zinc-600" : "cursor-pointer", !off && !on && "bg-zinc-100 transition-colors hover:bg-zinc-200/80 dark:bg-white/6 dark:hover:bg-white/10", )} > {on && ( )} {t} ); })}
)}
{/* The button wakes once a slot is chosen, then spins and lands on a drawn check. */} {phase === "loading" && ( )} {phase === "done" && ( )} {phase === "idle" && ( )}
{phase === "done" && day && time && ( onAddToCalendar?.({ date: day, time })} still={still} /> )} {status}
); }, );