'use client' /** * `` — natively-branded booking flow over the * HubSpot Meetings scheduler, rendered entirely with ODS primitives (no * iframe, no third-party chrome, no scripts). Data flows exclusively through * a HOST PROXY (`GET {apiBaseUrl}/api/meetings/availability`, * `POST {apiBaseUrl}/api/meetings/book`) — the widget has zero HubSpot * knowledge and no secrets; see `docs/EMBEDDING_HUBSPOT_MEETINGS.md` for the * endpoint contract a host must serve (external embedders proxy through * their own backend — the book route is IP-rate-limited and bot-gated). * * LAYOUT (Calendly-anatomy, conversion-first): one bordered card, two * panels. Left/top — the CONTEXT panel: an optional host-level back edge * (`onBack`), host identity (avatar + name + title from * `availability.hosts`), meeting title/description, duration chips (the * duration choice lives here, NOT as a wizard step — the visitor always * lands straight on the calendar), and the resolved timezone. * Right/bottom — the ACTION panel: calendar + time slots (first available * day auto-selected so slots are visible immediately), then the details * form, then the confirmation. On the slot step that panel splits again — * calendar column, then times column — each carrying its own heading (owned * by `SlotPicker`, so loading and loaded agree). Panels stack on mobile. * * Two modes, one component: * - SSR mode: pass `initialAvailability` (host-fetched) — the * zone-independent shell (context panel, calendar) renders server-side; * slot TIME labels wait for the client zone resolution (deterministic * first paint, no hydration mismatch), and availability is refetched * unconditionally after mount (the seed is scaffolding for a 60s-volatile * resource, not truth). * - Client mode: omit the seed — skeleton + self-fetch on mount. * * State machine: slot → details → confirmed, with a back edge, a "book * another" reset, and a `submitting` lock as the double-booking guard. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '../../utils/cn' import { formatDurationCompact } from '../../utils/format' import { Button } from '../ui' import { useHumanitySignals } from '../../hooks/use-humanity-signals' import { useMeetingBooking } from '../../hooks/use-meeting-booking' import { isSupportedFormField, type BookingConfirmation, type MeetingAvailability, type MeetingBookingErrorCode, type MeetingHost, } from '../../schemas/meeting-booking-schema' import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel' import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker' import { BookingForm } from './booking-form' import { Confirmation } from './confirmation' export interface HubSpotMeetingSchedulerProps { /** Directory id of the meeting link (from the host's `/api/meetings` payload). */ meetingId: string /** Endpoints prefix, default '' (same-origin `/api/meetings/*`) — FaqSection precedent. */ apiBaseUrl?: string /** SSR-mode seed (host-fetched). Omitted → client mode (self-fetch on mount). */ initialAvailability?: MeetingAvailability /** Meeting title shown in the context panel (host page owns the h1). */ title?: string /** Short description under the title in the context panel. */ description?: string | null /** Override the hosts shown (defaults to `availability.hosts`). */ hosts?: MeetingHost[] /** Pin the DISPLAY zone (rendering only — never sent upstream, never a cache key). */ displayTimezone?: string /** The link's public HubSpot booking URL — the "Open in HubSpot" escape hatch target. */ fallbackUrl?: string /** * Host-level exit, rendered as a back edge at the top of the context panel * (and in the loading skeleton, so it doesn't pop in). This is "leave the * scheduler", NOT the details step's back edge — it is suppressed once a * slot is chosen so only one Back is ever on screen. Omit it and no back * affordance renders at all. */ onBack?: () => void onBooked?: (b: BookingConfirmation) => void className?: string } type Step = 'slot' | 'details' | 'confirmed' /** Context-panel geometry — ONE definition for the loaded card and the * loading skeleton, which is what keeps the two footprint-identical. */ const CONTEXT_PANEL_CLASS = 'p-[var(--spacing-system-l)] shrink-0 lg:w-[280px] border-b lg:border-b-0 lg:border-r border-ods-border lg:min-h-0 lg:overflow-y-auto' /** * Action panel: the elastic half, scrolling inside the fixed card. * * Padded only from `lg`. Below it the calendar and times are separate sections * with a divider between them, and each pads itself — one padding here would * inset that divider from the card's edges instead of letting it run the full * width the way every other rule in this card does. */ const ACTION_PANEL_CLASS = 'flex-1 min-w-0 flex flex-col md:min-h-0 lg:p-[var(--spacing-system-l)]' /** * The action panel's inset for the steps that are ONE block — details, * confirmation, the slot step's error line. * * The panel itself is padded only from `lg`, because below that the slot step * is two sections with a rule between them and each pads itself: one padding * on the panel would inset that rule instead of letting it run the card's full * width, the way every other rule here does. Every step that is NOT split into * sections has to bring the same inset with it — without it the form ran edge * to edge on tablet, its inputs touching the card's border. * * `md:min-h-0 md:overflow-y-auto` comes with it: from `md` up the card states * a height, and these steps have no inner scroller of their own the way the * times column does, so this is where a long form gets to scroll instead of * being cut off at the card's edge. */ const PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-auto lg:p-0' /** * The widget's height on desktop: a FIXED 380px, and the ONLY size the whole * card states. * * A floor was not enough. Every stage has a different natural height — the * context panel with two hosts ~340, the calendar column ~290, a degraded * stage two lines — so `min-height` only pinned the SHORT ones and let the * tall one push the wrapper, which is exactly the screen-shake this removes. * With a fixed height the box is stated once and nothing inside can move it; * anything taller scrolls in place. * * The elastic half derives from it: the panel passes it to the slot row and * the times column scrolls inside what is left. The calendar does not stretch * — a date grid has a size, and stretching one is how a month turns into * page-tall bands the moment a layout stops pinning a height. It states a * WIDTH (`CALENDAR_W`) and its square cells make it as tall as it is wide; * that width is chosen to fit inside this number, and the two are checked * against each other in one place rather than three. * * Six week rows, always — `fixedWeeks` — is why a height can be stated at all: * a 5-week month and a 6-week month occupy the same box, so paging the * calendar cannot resize the card. * * Exported because the stability has to survive OUTSIDE the card too: a host * that swaps the scheduler in and out of a slot (the onboarding "Book a call" * promo does exactly that) reserves the same box for whatever it shows * instead, so the swap moves nothing below it. * * Goes on the BORDERED element, always. Under `box-sizing: border-box` the * declared height swallows the 1px edges, so a card that carries the border * and a host box that carries its own both come out at exactly 380 — put it on * an inner wrapper instead and the border lands OUTSIDE the 380, making that * card 2px taller than everything it is supposed to match. * * TWO numbers, because the card has two shapes: 380 as a sidebar beside the * calendar (`lg`), 550 as a header strip over it (`md`). Phones get neither — * there a card the height of the hand holding it is the right answer and the * page is the scroller. * * A host that reserves this box owes it one thing: content that ADAPTS to the * height rather than assuming it. The onboarding promo does it by letting its * video take the leftover space at 16:9; a stand-in that just stacks fixed * blocks will overflow the shorter of the two. */ export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]' /** * The two-panel card's box, shared verbatim by the loading skeleton and the * loaded card so neither can drift from the other. * * Both heights come from {@link MEETING_SCHEDULER_H}; what this adds is the * flex column that makes them work. On TABLET the height is stated for the * same reason the desktop one is: a CEILING only pins the tall stages. The short ones — the empty * month ("No available times in September"), a five-week month, the cold-start * skeleton — each settled at their own natural height, so paging a month or * waiting out a fetch resized the card under the visitor. Stating the height * makes every stage the same box and moves the variation inside, where the * times column already scrolls. * * 550, not the mock's 498: the mock draws one host and no timezone field, and * a real link has two hosts and a zone the visitor must be able to change. * That is ~55px of content the mock never budgeted for, and the month below it * is what would otherwise pay for it. * * The strip is `shrink-0`, so what is left goes to the slot row, which spends * it on a calendar sized to fit (no scroll) and a times list that scrolls on * its own. * * `md:flex md:flex-col` is what makes that possible at all. A stated height on * a plain block only clips (this card is `overflow-hidden` for its corners) — * it is the flex column plus the inner wrapper's `min-h-0` that lets the * content shrink into the height instead of being cut off by it. */ const CARD_CLASS = cn( 'rounded-md border border-ods-border bg-ods-card overflow-hidden', 'md:flex md:flex-col', MEETING_SCHEDULER_H, ) /** Context strip over action panel, side by side from `lg`. `md:flex-1 * md:min-h-0` is the pair that makes it exactly as tall as the card states — * grow into a stated height that content does not reach, shrink into one it * overruns — so neither a short stage nor a long one changes the box. */ const CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-row' /** The two-line stages (load failure, "booked on HubSpot") — the SAME box as * the booking card, because these are stages of one widget in one slot and a * stage that collapses to two lines would snap the page shut under the * visitor. Their content is centred in it and scrolls if a message ever * outgrows it. */ const CARD_DEGRADED_CLASS = cn( 'rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)] flex flex-col items-start gap-[var(--spacing-system-m)]', 'md:justify-center md:overflow-y-auto', MEETING_SCHEDULER_H, ) /** * Fail-closed gate: a link whose declared questions include an unsupported * type, or whose consent block is malformed, must NOT render a half-working * native form (a silently dropped required question or missing consent copy * is worse than no native form) — the escape hatch takes over. */ function isNativelyBookable(availability: MeetingAvailability): boolean { if (!availability.formFields.every(isSupportedFormField)) return false const consent = availability.legalConsent if (consent) { if (typeof consent.processingConsentText !== 'string') return false if (!Array.isArray(consent.communicationConsentCheckboxes)) return false } return true } export function HubSpotMeetingScheduler({ meetingId, apiBaseUrl = '', initialAvailability, title, description, hosts, displayTimezone, fallbackUrl, onBack, onBooked, className, }: HubSpotMeetingSchedulerProps) { const { availability, isLoadingAvailability, isFetchingAvailability, availabilityError, monthOffset, setMonthOffset, refetchAvailability, book, isSubmitting, } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability }) // Zone resolution happens POST-mount (Intl) unless the embedder pins one — // the initial (server) render stays deterministic; time labels appear once // a zone exists. const [timezone, setTimezone] = useState(displayTimezone ?? null) useEffect(() => { if (displayTimezone) return try { setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC') } catch { setTimezone('UTC') } }, [displayTimezone]) const [step, setStep] = useState('slot') const [durationMs, setDurationMs] = useState(null) const [selectedDay, setSelectedDay] = useState(null) const [selectedSlot, setSelectedSlot] = useState(null) const [confirmation, setConfirmation] = useState(null) const [bookingError, setBookingError] = useState(null) const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals() // Reset the machine when the host switches links. useEffect(() => { setStep('slot') setDurationMs(null) setSelectedDay(null) setSelectedSlot(null) setConfirmation(null) setBookingError(null) autoAdvanceCount.current = 0 }, [meetingId]) const durations = availability?.durationsMs ?? [] const shownHosts = hosts ?? availability?.hosts ?? [] // Default to the first offered duration — the visitor lands straight on // the calendar; duration is a context-panel chip, not a wizard step. useEffect(() => { if (durationMs == null && durations.length > 0) setDurationMs(durations[0]) }, [durationMs, durations]) const slots = useMemo(() => { if (!availability || durationMs == null) return [] return availability.slotsByDurationMs[String(durationMs)] ?? [] }, [availability, durationMs]) // Auto-select the first day WITH SLOTS IN THE VISIBLE MONTH (once the zone // is known) so the visitor sees concrete times immediately — never a dead // "pick a day" state, and NEVER a day from another month than the calendar // shows (HubSpot's monthOffset payloads can carry near-term slots outside // the requested month — the visible grid is the authority). useEffect(() => { if (step !== 'slot' || !timezone) return const now = new Date() const visible = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1) const monthPrefix = `${visible.getFullYear()}-${String(visible.getMonth() + 1).padStart(2, '0')}` const inMonth = slots.filter((ms) => dayKeyInZone(ms, timezone).startsWith(monthPrefix)) if (inMonth.length === 0) { if (selectedDay && !selectedDay.startsWith(monthPrefix)) setSelectedDay(null) return } const dayKeys = new Set(inMonth.map((ms) => dayKeyInZone(ms, timezone))) if (selectedDay && dayKeys.has(selectedDay)) return setSelectedDay(dayKeyInZone(inMonth[0], timezone)) }, [step, timezone, slots, selectedDay, monthOffset]) // Fully-booked month with more ahead → auto-advance (bounded, ≤3 hops) // before showing the empty state. const autoAdvanceCount = useRef(0) useEffect(() => { if ( step === 'slot' && availability && !isFetchingAvailability && durationMs != null && slots.length === 0 && availability.hasMore && autoAdvanceCount.current < 3 ) { autoAdvanceCount.current += 1 setMonthOffset(monthOffset + 1) } }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset]) /** The back edge's destination from the form: the calendar, with any * submit error cleared so the visitor doesn't carry it back. */ const backToSlot = useCallback(() => { setStep('slot') setBookingError(null) }, []) const handleSubmit = useCallback( async (payload: Record) => { setBookingError(null) const result = await book(payload) if (result.ok && result.confirmation) { setConfirmation(result.confirmation) setStep('confirmed') onBooked?.(result.confirmation) return } const code = result.code ?? 'TEMPORARILY_UNAVAILABLE' setBookingError(code) if (code === 'SLOT_TAKEN') { // Recover: refresh the grid (the slot vanishes) and reset the timing // signal so the retry isn't flagged too-fast. setSelectedSlot(null) setStep('slot') resetSignals() void refetchAvailability() } }, [book, onBooked, refetchAvailability, resetSignals], ) const escapeHatch = fallbackUrl ? ( ) : null // ---- terminal / degraded states ----------------------------------------- if (isLoadingAvailability && !availability) { // COLD start only — a month already in the query cache renders straight // into the loaded card. Composed inside the SAME wrappers as that card // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing // shifts when it swaps. return (
) } if (availabilityError || !availability) { return (

We couldn't load available call times. Please try again shortly.

{escapeHatch}
) } if (!isNativelyBookable(availability)) { // Fail closed — never render a half-working native form on a link with // questions or consent we can't faithfully reproduce. return (

This meeting type is booked directly on HubSpot.

{escapeHatch}
) } // ---- the card ------------------------------------------------------------ return (
{ setDurationMs(ms) setSelectedDay(null) setSelectedSlot(null) if (step === 'details') setStep('slot') }} timezone={timezone} onTimezoneChange={setTimezone} // ONE back edge for the whole card, in the panel that never swaps. // Its destination is simply "the previous step": from the form back // to the calendar, from the calendar out of the scheduler (if the // host gave us an exit at all). Two Backs on screen reading the same // word with different destinations was the ambiguity the details // step used to carry. onBack={step === 'details' ? backToSlot : onBack} locked={step !== 'slot'} // The zone still governs every time on screen — including the // summary line on the form — so the picker stays until there is // nothing left to re-read in it. showTimezone={step !== 'confirmed'} className={CONTEXT_PANEL_CLASS} /> {/* Scrolls INSIDE the fixed card rather than growing it — see MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's default `min-height:auto` refuses to shrink below its content. */}
{step === 'confirmed' && confirmation && timezone ? (
) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
{/* Top-aligned, and no back edge of its own: the ONE back edge lives in the context panel at every step (see `contextBack`), which is where the design puts it and the only spot that stays put while this side swaps between calendar and form. */}

{new Intl.DateTimeFormat(undefined, { timeZone: timezone, weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', }).format(new Date(selectedSlot))}{' '} · {formatDurationCompact(durationMs / 1000)}

{bookingError && bookingError !== 'SLOT_TAKEN' && (

{bookingError === 'TEMPORARILY_UNAVAILABLE' ? 'Scheduling is briefly unavailable — please try again in a minute.' : bookingError === 'MEETING_UNAVAILABLE' ? 'This meeting type has reached its booking limit for today — try another time or contact us.' : bookingError === 'LINK_GONE' ? 'This meeting type is no longer available.' : 'Please double-check your details and try again.'}

{escapeHatch}
)}
) : (
{bookingError === 'SLOT_TAKEN' && (

That time is no longer available — if you just submitted, check your email for a confirmation before rebooking.

)} {timezone && durationMs != null ? ( // The picker owns BOTH the loading and the empty month now — // it keeps the calendar up either way, so there is no branch // here that can take it off screen mid-flow. { setSelectedDay(null) setSelectedSlot(null) setMonthOffset(o) }} selectedSlot={selectedSlot} onSelectSlot={(ms) => { setSelectedSlot(ms) setStep('details') }} selectedDay={selectedDay} onSelectDay={setSelectedDay} isLoading={isLoadingAvailability} /> ) : durations.length === 0 && !isFetchingAvailability ? ( // The LINK publishes nothing at all — a different thing from a // fully-booked month, and the only case the escape hatch is // the right answer to.

No call times are published right now.

{escapeHatch}
) : ( // Pre-mount timezone-resolution window (every SSR load hits // this for one frame-batch) — same layout, real calendar, so // nothing shifts when the picker takes over. )}
)}
) } export { MeetingSchedulerDirectory, MeetingSchedulerDirectoryRowSkeleton, type MeetingSchedulerDirectoryProps, } from './directory' export type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost } export type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema'