'use client' import { useCallback, useMemo } from 'react' import { ChevronRight } from 'lucide-react' import { Button, DatePickerCalendar, Skeleton } from '../ui' import { cn } from '../../utils/cn' import { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention' /** * SlotPicker — day layer + time-chip grid for the meeting scheduler. * * Day layer is the design system's own `DatePickerCalendar`, in its `bare` * variant — the SAME component behind `DatePicker`'s popover and * `DateFilterMenu`, not a second day grid styled to look like it; `bare` only * drops the card surface and inset it would otherwise draw inside this panel. `fromDate`/`toDate` pin * navigation to [current month, current + MAX_MONTH_OFFSET] so the past is * unreachable, and `disabledDays` greys out days with no bookable slot — * bounds and availability are different questions, so they arrive separately. * Month navigation drives the host-proxy `monthOffset`, which is why the month * is CONTROLLED here rather than owned by the calendar. Time chips are plain * `Button`s whose variant flips on selection — no bespoke pill. * * LOADING (no blink, ever): the two columns know different amounts, so they * load differently. A CALENDAR is fully derivable from the date — which month, * which weekday each cell falls on — and only WHICH DAYS ARE BOOKABLE needs * the network. So it never unmounts: while a month loads it renders for real * with nothing yet selectable, which is exactly true, and lights up in place. * Only the times column, which genuinely has no answer until the response * lands, shows a placeholder. * * Swapping the calendar for a skeleton was the old behaviour and it is what * made paging months flash: the whole grid vanished and rebuilt to say * something it already knew. * * Each column carries its OWN heading — "Select Date & Time" over the * calendar, the chosen day over the chips. The headings live HERE rather than * above the whole picker so each sits with the thing it labels, and so the * skeletons reproduce them without the parent rendering a heading it cannot * keep in step. * * All slot instants are epoch-ms; every label is rendered in `timezone` * (resolved by the parent AFTER mount — this component never reads Intl * itself, keeping SSR output deterministic). */ export interface SlotPickerProps { /** Bookable slot start times (epoch ms) for the selected duration. */ slots: number[] /** IANA zone every label renders in (parent resolves post-mount). */ timezone: string monthOffset: number onMonthOffsetChange: (offset: number) => void selectedSlot: number | null onSelectSlot: (startMs: number) => void selectedDay: string | null onSelectDay: (dayKey: string) => void /** Availability refetch in flight (month change) → per-region skeletons. */ isLoading?: boolean } /** Stable per-zone day key for an instant, e.g. "2026-08-14" (exported — the * parent uses it to auto-select the first available day). */ export function dayKeyInZone(ms: number, timeZone: string): string { return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format( new Date(ms), ) } /** * Calendar-side day-key anchoring: react-day-picker exchanges PLAIN calendar * dates (local Date objects whose y/m/d ARE the cell). Convert via local * date parts — never through an Intl zone — or a `displayTimezone` far from * the browser zone shifts cells by a day. */ const pad2 = (n: number) => String(n).padStart(2, '0') function calendarDayKey(day: Date): string { return `${day.getFullYear()}-${pad2(day.getMonth() + 1)}-${pad2(day.getDate())}` } function dateFromDayKey(key: string): Date { const [y, m, d] = key.split('-').map(Number) return new Date(y, m - 1, d, 12) } function timeLabelInZone(ms: number, timeZone: string): string { return new Intl.DateTimeFormat(undefined, { timeZone, hour: 'numeric', minute: '2-digit' }).format(new Date(ms)) } /** * The calendar column's width, once the layout stops handing it one: on a * phone the section spans the card, from `md` up it takes a stated width and * the times column gets the rest. * * Width no longer decides height — `DatePickerCalendar bare` is a fixed 272px * block at any width — so these two numbers answer one question only: how much * room the month CAPTION needs. The two DS icon buttons and their gaps take * 104px of the header, and "September 2026" is ~131 at text-h4, so the grid * needs ~245px of inner width before the label starts colliding with the * next-month button. 304 on tablet (256 inner, this column pads itself) and * 256 on the desktop card, where the panel supplies the padding. * * That is the trade the standard button size buys: the chrome keeps its * 48px targets and the COLUMN widens to house it, rather than the buttons * shrinking to fit a narrower column. */ const CALENDAR_W = 'md:w-[19rem] md:shrink-0 lg:w-[16rem]' /** Column heading ("Select Date & Time" / the chosen day) — body-weight * primary, 4px above its content, mirrored by both skeletons. */ const COLUMN_HEADING_CLASS = 'shrink-0 text-h4 text-ods-text-primary' const COLUMN_STACK = 'flex min-h-0 flex-col gap-[var(--spacing-system-xxs)]' /** Static label over the calendar. Exported so a host rendering its own * chrome around the picker can stay in step with it. */ export const SLOT_PICKER_HEADING = 'Select Date & Time' /** * Chip grid: `auto-fill` over `minmax(5.5rem, 1fr)` — as many chips per row as * fit at the minimum, then the leftover width shared out between them. * * Both halves matter. The 5.5rem MINIMUM is the widest 12-hour label * ("12:45 PM") plus the chip's padding: the mocks draw 65px chips five across * a phone, but they are lettered in a 24-hour locale ("13:45") and this * renders in the visitor's own — a chip that clips its own time is worse than * one column fewer. The `1fr` MAXIMUM is what makes the grid reach the card's * edge like the design instead of stopping short with a ragged strip of empty * space beside the last column, which is what a fixed track width leaves * behind at every width that is not an exact multiple of it. * * `auto-fill` is what keeps `1fr` safe here: the track COUNT still comes from * the minimum, so a wide column answers with more chips rather than with * fifteen-minute slots stretched into 200px slabs. * * Every track is the same width, so rows line up and nothing staggers. */ const CHIP_GRID_CLASS = cn( 'grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] content-start gap-[var(--spacing-system-xs)]', 'overflow-y-auto', // PHONE: a stated window (~3 rows, the mock's 156px) that scrolls, rather // than a list that runs the length of the day. Nothing else on the phone // card has a height — the design lets each step be as tall as it needs — // but a busy day is forty slots, and letting them all through turns a // 700px card into a 1500px one and buries whatever the page puts below // it. This is the one nested scroller worth having: a small, obviously // bounded list with a visible edge, exactly as drawn. 'max-h-[9.75rem] md:max-h-none', // No height of its own, and none borrowed from the calendar either: the card // states one height, the panel and this row pass it down, and what is left // after the column heading IS the scroll box. A long day scrolls inside it. // // `min-h-0` is the load-bearing half — a flex item's default // `min-height:auto` refuses to shrink below its content, so without it the // chips grow the column, then the row, then the card, and `overflow-y-auto` // never gets a box smaller than its contents to scroll inside. That is the // exact failure that kept this card at 404px when it was told to be 318. 'flex-1 min-h-0', ) /** The track sets the width; the chip fills it, so labels can't stagger it. * The tighter inline padding is what lets the track hold a 12-hour label — * the Button's own is sized for prose, not for a time. */ const CHIP_CLASS = 'w-full px-[var(--spacing-system-xxs)]' /** * The two columns, at three widths. * * < md phone — stacked sections, a full-bleed rule between them * md tablet — side by side, the calendar at its stated width and the * times taking the rest, a vertical rule between them * lg desktop — side by side inside the panel's own padding, separated by * a gap (the card already has a rule there: the context * panel's edge) * * From `md` up the row takes what the card has left (`flex-1 min-h-0`) and * hands each column that same definite height. The calendar is a fixed 272px * block and does not scroll; the times column holds whatever the day offers * and scrolls ON ITS OWN. Two boxes, one height, one scrollbar — the calendar * stays put while the visitor runs down the times. * * Below `md` the card pins no height, so the row simply grows and the PAGE * scrolls — a nested scroller inside a hand-width card traps the gesture and * is worse than the thing it saves. */ const SLOT_ROW_CLASS = cn( 'flex min-w-0 flex-col md:flex-row', // Default `stretch` at every width, deliberately. Stacked, that is what // makes each section span the card — an `items-start` here (it was here, to // stop a column growing taller than its month) collapses both sections to // their intrinsic WIDTH instead, which on a phone left the calendar and the // chips huddled against the left edge with half the card empty. The // calendar no longer needs protecting: its height is fixed, not derived. 'md:min-h-0 md:flex-1 lg:gap-[var(--spacing-system-lf)]', ) /** Shared by both columns: self-padded until the panel takes over at `lg`. * Widths are per-column — the calendar states one (`CALENDAR_W`) and the * times take what is left, rather than a 50/50 split that would size the * month by the card's width. */ const COLUMN_BOX = 'p-[var(--spacing-system-l)] lg:p-0' /** The rule between the two, wherever the layout puts it. */ const COLUMN_DIVIDER = 'border-b border-ods-border md:border-b-0 md:border-r lg:border-0' /** Deterministic month caption for a given offset (fixed en-US locale so * the SSR and client first-paint markup agree byte-for-byte). */ export function monthLabelFor(offset: number): string { const now = new Date() const month = new Date(now.getFullYear(), now.getMonth() + offset, 1) return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month) } /** Nothing is bookable yet — used while a month is still loading. */ const DISABLE_EVERY_DAY = () => true const NOOP = () => undefined interface DayCalendarProps { monthOffset: number onMonthOffsetChange: (offset: number) => void selectedDay: string | null onSelectDay: (dayKey: string) => void /** Days that ARE bookable; `null` while the month is still loading. */ slotsByDay: Map | null } /** * The day layer — one component for every state, which is the whole point: * loading a month changes WHICH DAYS ARE ENABLED, nothing else, so the grid * stays mounted and the visitor never watches it disappear and come back. * * `aria-busy` carries what the missing days mean to assistive tech, since * visually it is just an inert month. */ function DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDay, slotsByDay }: DayCalendarProps) { const now = new Date() const startMonth = new Date(now.getFullYear(), now.getMonth(), 1) // Day-0 of the FOLLOWING month = the last day of the last bookable one. // `DatePickerCalendar` takes day bounds, not month bounds, so an end date // pointing at the 1st would disable the rest of that month. const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET + 1, 0) const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1) // Stable identity: the calendar memoizes its matcher list on this value, so // a fresh closure per render would rebuild it every render. const dayHasNoSlots = useCallback( (day: Date) => !slotsByDay?.has(calendarDayKey(day)), [slotsByDay], ) return (
{ const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth()) onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset))) }} fromDate={startMonth} toDate={endMonth} selected={selectedDay ? dateFromDayKey(selectedDay) : undefined} onSelect={(value) => { const day = value as Date | undefined if (!day) return const key = calendarDayKey(day) if (slotsByDay?.has(key)) onSelectDay(key) }} disabledDays={slotsByDay === null ? DISABLE_EVERY_DAY : dayHasNoSlots} />
) } /** * The slot area before the first response — the pre-mount window while the * display timezone resolves, and the first availability request. * * The calendar half is REAL, not a placeholder: see the module docblock. Only * the times column is a skeleton, and it is the same one the loaded picker * shows while a month loads, so there is no swap between the two. */ export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) { return (
{/* Needs no data — the heading renders REAL in every loading state. */}

{SLOT_PICKER_HEADING}

) } /** Same-footprint skeleton for the time-chip column. */ export function TimeChipsSkeleton() { return ( <> {/* The day label is data — but it is held inside the REAL heading line box, so the skeleton's first row sits exactly where the label lands. */}
{/* Enough to fill the visible scroll box at any column count — the grid's track count follows the card width, so no fixed number lands flush and a partial last row is the honest shape anyway. */} {Array.from({ length: 16 }, (_, i) => ( // h-11 md:h-12 — the loaded chip's own default-size height. ))}
) } export function SlotPicker({ slots, timezone, monthOffset, onMonthOffsetChange, selectedSlot, onSelectSlot, selectedDay, onSelectDay, isLoading = false, }: SlotPickerProps) { const slotsByDay = useMemo(() => { const map = new Map() for (const ms of slots) { const key = dayKeyInZone(ms, timezone) const list = map.get(key) ?? [] list.push(ms) map.set(key, list) } for (const list of map.values()) list.sort((a, b) => a - b) return map }, [slots, timezone]) const visibleMonth = useMemo(() => { const now = new Date() return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1) }, [monthOffset]) const daySlots = selectedDay ? (slotsByDay.get(selectedDay) ?? []) : [] // Whether the VISIBLE month has anything at all. HubSpot's monthOffset // payloads can carry near-term slots from outside the requested month, so // this asks about the grid on screen, not about the payload's size. const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}` const monthHasSlots = [...slotsByDay.keys()].some((key) => key.startsWith(monthPrefix)) return (

{SLOT_PICKER_HEADING}

{/* Stays mounted through a month load — `slotsByDay: null` is what "nothing selectable yet" looks like, not an unmount. */}
{isLoading ? ( ) : selectedDay ? ( <>

{/* Format the DAY-KEY itself (plain calendar date) — re-zoning a local instant could label an adjacent day. */} {new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric', }).format(dateFromDayKey(selectedDay))}

{daySlots.map((ms) => { const isSelected = selectedSlot === ms return ( ) })}
) : monthHasSlots ? (

Pick a day to see available times.

) : ( // A fully-booked month used to just SAY "try the next one" and leave // the visitor to find the chevron in the other column. The way out // of a dead end belongs in the dead end.

No available times in {monthLabelFor(monthOffset)}.

{monthOffset < MAX_MONTH_OFFSET && ( )}
)}
) }