{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","../../../src/components/meeting-scheduler/index.tsx","../../../src/hooks/use-meeting-booking.ts","../../../src/schemas/meeting-booking-schema.ts","../../../src/components/meeting-scheduler/booking-form.tsx","../../../src/components/meeting-scheduler/confirmation.tsx","../../../src/components/meeting-scheduler/context-panel.tsx","../../../src/components/meeting-scheduler/slot-picker.tsx","../../../src/components/meeting-scheduler/directory.tsx"],"names":["useState","Fragment","jsx","jsxs","useMemo","useCallback"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/meeting-scheduler/index.cjs","sourcesContent":[null,"'use client';\n\n/**\n * `<HubSpotMeetingScheduler />` — natively-branded booking flow over the\n * HubSpot Meetings scheduler, rendered entirely with ODS primitives (no\n * iframe, no third-party chrome, no scripts). Data flows exclusively through\n * a HOST PROXY (`GET {apiBaseUrl}/api/meetings/availability`,\n * `POST {apiBaseUrl}/api/meetings/book`) — the widget has zero HubSpot\n * knowledge and no secrets; see `docs/EMBEDDING_HUBSPOT_MEETINGS.md` for the\n * endpoint contract a host must serve (external embedders proxy through\n * their own backend — the book route is IP-rate-limited and bot-gated).\n *\n * LAYOUT (Calendly-anatomy, conversion-first): one bordered card, two\n * panels. Left/top — the CONTEXT panel: an optional host-level back edge\n * (`onBack`), host identity (avatar + name + title from\n * `availability.hosts`), meeting title/description, duration chips (the\n * duration choice lives here, NOT as a wizard step — the visitor always\n * lands straight on the calendar), and the resolved timezone.\n * Right/bottom — the ACTION panel: calendar + time slots (first available\n * day auto-selected so slots are visible immediately), then the details\n * form, then the confirmation. On the slot step that panel splits again —\n * calendar column, then times column — each carrying its own heading (owned\n * by `SlotPicker`, so loading and loaded agree). Panels stack on mobile.\n *\n * Two modes, one component:\n *  - SSR mode: pass `initialAvailability` (host-fetched) — the\n *    zone-independent shell (context panel, calendar) renders server-side;\n *    slot TIME labels wait for the client zone resolution (deterministic\n *    first paint, no hydration mismatch), and availability is refetched\n *    unconditionally after mount (the seed is scaffolding for a 60s-volatile\n *    resource, not truth).\n *  - Client mode: omit the seed — skeleton + self-fetch on mount.\n *\n * State machine: slot → details → confirmed, with a back edge, a \"book\n * another\" reset, and a `submitting` lock as the double-booking guard.\n * `flow=\"details-first\"` (opt-in, campaign landing pages) inverts it: the\n * form is step ONE and renders alone, the slot click submits the frozen\n * answers, and a details error routes back to the repopulated form — see\n * `SCHEDULER_FLOW_PRESETS` and the `detailsFirst` branches below.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport { useHumanitySignals } from '../../hooks/use-humanity-signals';\nimport { BOOKING_IN_FLIGHT_MESSAGE, useMeetingBooking } from '../../hooks/use-meeting-booking';\nimport { useToast } from '../../hooks/use-toast';\nimport {\n  isSupportedFormField,\n  type BookingConfirmation,\n  type MeetingAvailability,\n  type MeetingBookingErrorCode,\n  type MeetingHost,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { formatDurationCompact } from '../../utils/format';\nimport { Alert, AlertDescription, Button } from '../ui';\nimport { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';\nimport { Confirmation } from './confirmation';\nimport { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';\nimport { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';\n\n/**\n * Visitor-facing copy per booking-error code. Everything except SLOT_TAKEN\n * surfaces as an error TOAST at submit time, and ONLY as a toast (owner\n * decision 2026-08-27 — no inline duplicate; hosts must mount the lib\n * Toaster, as every hub platform does globally). SLOT_TAKEN renders above\n * the calendar instead: its recovery flow returns the visitor there. An\n * unrecognized code (newer host than widget) falls back to the VALIDATION\n * copy — fail-safe, never blank.\n */\nconst BOOKING_ERROR_COPY: Record<Exclude<MeetingBookingErrorCode, 'SLOT_TAKEN'>, string> = {\n  INVALID_EMAIL:\n    'That email address was rejected by our scheduling system — please use a real, reachable address (work email works best).',\n  VALIDATION:\n    'Please double-check your details — especially that the email address is real and reachable — and try again.',\n  TEMPORARILY_UNAVAILABLE: 'Scheduling is briefly unavailable — please try again in a minute.',\n  MEETING_UNAVAILABLE: 'This meeting type has reached its booking limit for today — try another time or contact us.',\n  LINK_GONE: 'This meeting type is no longer available.',\n};\n\nexport interface HubSpotMeetingSchedulerProps {\n  /** Directory id of the meeting link (from the host's `/api/meetings` payload). */\n  meetingId: string;\n  /** Endpoints prefix, default '' (same-origin `/api/meetings/*`) — FaqSection precedent. */\n  apiBaseUrl?: string;\n  /** SSR-mode seed (host-fetched). Omitted → client mode (self-fetch on mount). */\n  initialAvailability?: MeetingAvailability;\n  /** Meeting title shown in the context panel (host page owns the h1). */\n  title?: string;\n  /** Short description under the title in the context panel. */\n  description?: string | null;\n  /** Override the hosts shown (defaults to `availability.hosts`). */\n  hosts?: MeetingHost[];\n  /** Pin the DISPLAY zone (rendering only — never sent upstream, never a cache key). */\n  displayTimezone?: string;\n  /** The link's public HubSpot booking URL — the \"Open in HubSpot\" escape hatch target. */\n  fallbackUrl?: string;\n  /**\n   * Host-level exit, rendered as a back edge at the top of the context panel\n   * (and in the loading skeleton, so it doesn't pop in). This is \"leave the\n   * scheduler\", NOT the details step's back edge — it is suppressed once a\n   * slot is chosen so only one Back is ever on screen. Omit it and no back\n   * affordance renders at all.\n   */\n  onBack?: () => void;\n  onBooked?: (b: BookingConfirmation) => void;\n  className?: string;\n  /**\n   * Panel order.\n   *\n   * `'slot-first'` (default, unchanged): calendar → details → confirmed.\n   * `'details-first'`: the form comes FIRST and the slot click submits the\n   * already-validated payload. Campaign landing pages use it, where \"tell us\n   * about your setup, then pick a time\" is the conversion shape.\n   *\n   * This is not a cosmetic swap — see the flow-dependent branches below. It is\n   * opt-in precisely so `slot-first` stays byte-identical.\n   */\n  flow?: SchedulerFlow;\n  /**\n   * Replace the details panel — step ONE under `flow=\"details-first\"`, step two\n   * otherwise — with a host-supplied form. It receives exactly what the built-in\n   * one does, so the injected form keeps the widget's contract: the deferred\n   * schema, the honeypot and elapsed-ms signals, the verbatim consent block, the\n   * `onSubmit` handoff and the `isSubmitting` lock.\n   *\n   * The intended shape is a THIN wrapper that re-renders `BookingForm` with\n   * `fieldRows` (a layout re-arrangement, all machinery reused), not a\n   * hand-written form. Anything reimplementing the contract loses the bot\n   * protection and the consent guarantees.\n   *\n   * A COMPONENT type, not a render callback: React then owns its identity, so\n   * it reconciles across steps and keeps its own state instead of remounting —\n   * and the honeypot `ref` rides through as a normal prop.\n   *\n   * Defaults to the built-in form, so every existing embed is untouched.\n   */\n  detailsForm?: ComponentType<BookingFormProps>;\n  /**\n   * The DATA form of the same override: `fieldRows` and a host consent row,\n   * spread onto whichever form renders. Serialisable, so a Server Component\n   * can pass it across the RSC boundary where a component cannot.\n   */\n  detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;\n}\n\ntype Step = 'slot' | 'details' | 'confirmed';\n\n/** Values collected at the details step, held until a slot picks the instant. */\ntype StashedDetails = {\n  payload: Record<string, unknown>;\n  /** Humanity signals captured while the form (and its honeypot) was mounted. */\n  signals: Record<string, string | number>;\n};\n\n/** Context-panel geometry — ONE definition for the loaded card and the\n *  loading skeleton, which is what keeps the two footprint-identical. */\nconst CONTEXT_PANEL_CLASS =\n  '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';\n\n/**\n * Action panel: the elastic half, scrolling inside the fixed card.\n *\n * Padded only from `lg`. Below it the calendar and times are separate sections\n * with a divider between them, and each pads itself — one padding here would\n * inset that divider from the card's edges instead of letting it run the full\n * width the way every other rule in this card does.\n */\nconst ACTION_PANEL_CLASS = 'flex-1 min-w-0 flex flex-col md:min-h-0 lg:p-[var(--spacing-system-l)]';\n\n/**\n * The action panel's inset for the steps that are ONE block — details,\n * confirmation, the slot step's error line.\n *\n * The panel itself is padded only from `lg`, because below that the slot step\n * is two sections with a rule between them and each pads itself: one padding\n * on the panel would inset that rule instead of letting it run the card's full\n * width, the way every other rule here does. Every step that is NOT split into\n * sections has to bring the same inset with it — without it the form ran edge\n * to edge on tablet, its inputs touching the card's border.\n *\n * `md:min-h-0 md:overflow-y-auto` comes with it: from `md` up the card states\n * a height, and these steps have no inner scroller of their own the way the\n * times column does, so this is where a long form gets to scroll instead of\n * being cut off at the card's edge.\n */\nconst PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-auto lg:p-0';\n/** A step's panel: the inset above on a column that fills the action side. */\nconst STEP_PANEL_CLASS = cn('flex flex-1 flex-col', PANEL_STEP_CLASS);\n\n/**\n * The widget's height on desktop: a FIXED 380px, and the ONLY size the whole\n * card states.\n *\n * A floor was not enough. Every stage has a different natural height — the\n * context panel with two hosts ~340, the calendar column ~290, a degraded\n * stage two lines — so `min-height` only pinned the SHORT ones and let the\n * tall one push the wrapper, which is exactly the screen-shake this removes.\n * With a fixed height the box is stated once and nothing inside can move it;\n * anything taller scrolls in place.\n *\n * The elastic half derives from it: the panel passes it to the slot row and\n * the times column scrolls inside what is left. The calendar does not stretch\n * — a date grid has a size, and stretching one is how a month turns into\n * page-tall bands the moment a layout stops pinning a height. It states a\n * WIDTH (`CALENDAR_W`) and its square cells make it as tall as it is wide;\n * that width is chosen to fit inside this number, and the two are checked\n * against each other in one place rather than three.\n *\n * Six week rows, always — `fixedWeeks` — is why a height can be stated at all:\n * a 5-week month and a 6-week month occupy the same box, so paging the\n * calendar cannot resize the card.\n *\n * Exported because the stability has to survive OUTSIDE the card too: a host\n * that swaps the scheduler in and out of a slot (the onboarding \"Book a call\"\n * promo does exactly that) reserves the same box for whatever it shows\n * instead, so the swap moves nothing below it.\n *\n * Goes on the BORDERED element, always. Under `box-sizing: border-box` the\n * declared height swallows the 1px edges, so a card that carries the border\n * and a host box that carries its own both come out at exactly 380 — put it on\n * an inner wrapper instead and the border lands OUTSIDE the 380, making that\n * card 2px taller than everything it is supposed to match.\n *\n * TWO numbers, because the card has two shapes: 380 as a sidebar beside the\n * calendar (`lg`), 550 as a header strip over it (`md`). Phones get neither —\n * there a card the height of the hand holding it is the right answer and the\n * page is the scroller.\n *\n * A host that reserves this box owes it one thing: content that ADAPTS to the\n * height rather than assuming it. The onboarding promo does it by letting its\n * video take the leftover space at 16:9; a stand-in that just stacks fixed\n * blocks will overflow the shorter of the two.\n */\nexport const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';\n\n/**\n * The same box for `flow=\"details-first\"`, where the tallest stage is the\n * DETAILS FORM (email + first/last + the link's declared questions + the\n * consent block + Continue) rather than the calendar.\n *\n * Still a fixed pair, not a floor: everything below the card derives a definite\n * height from it, and a floor was already tried here — it pinned the short\n * stages and let the tall one push the wrapper, which is the screen-shake the\n * fixed height exists to remove.\n *\n * ONE number from `md` up, not two: 638px, the card both mocks draw\n * (`4904:117130` form-only, `4904:118213` three columns). The form stage has no\n * sidebar to stack into a header strip, so nothing about it changes between\n * tablet and desktop, and the calendar stage fits the same box at both — the\n * times column scrolls inside it as it always has.\n *\n * PHONES get a stated height too — the one place this flow departs from\n * slot-first's \"the page is the scroller\". Slot-first's two stages are the\n * same shape on a phone (calendar + a capped times list either way), so it\n * needs none. Details-first's are not: the form stage, the calendar stage and\n * the two-line confirmation each have their own natural height, and a paid\n * landing page that grows and shrinks by tens of pixels on every step reads as\n * broken. 812px is the calendar stage's natural height at 375px (header strip\n * + six fixed weeks + the 9.75rem chip cap), so that stage fits exactly and the\n * form stage — shorter on the shipped links — sits in it; a link declaring more\n * questions scrolls the form inside the card, as it already does from `md`.\n *\n * Hosts read it through `SCHEDULER_FLOW_PRESETS[flow].height`, never directly.\n */\nexport const MEETING_SCHEDULER_DETAILS_FIRST_H = 'h-[50.75rem] md:h-[39.875rem]';\n\nexport type SchedulerFlow = 'slot-first' | 'details-first';\n\n/** The flow every existing embed gets — the ONE spelling of the default. */\nexport const DEFAULT_SCHEDULER_FLOW: SchedulerFlow = 'slot-first';\n\n/**\n * What differs between the two flows as DATA — first step, the box a host\n * reserves, the form's submit copy. Exported so a host that swaps the card in\n * and out reads `SCHEDULER_FLOW_PRESETS[flow].height` instead of re-deriving\n * the pairing. The behavioural branches (lock, back edge, error routing) stay\n * in the component: they are logic, not configuration.\n */\nexport const SCHEDULER_FLOW_PRESETS: Record<\n  SchedulerFlow,\n  { initialStep: Exclude<Step, 'confirmed'>; height: string; submitLabel: string; footerNote?: string }\n> = {\n  'slot-first': { initialStep: 'slot', height: MEETING_SCHEDULER_H, submitLabel: DEFAULT_SUBMIT_LABEL },\n  'details-first': {\n    initialStep: 'details',\n    height: MEETING_SCHEDULER_DETAILS_FIRST_H,\n    submitLabel: 'Continue',\n    footerNote: 'Next step: pick a slot that works. Calendar invite lands right after.',\n  },\n};\n\n/**\n * The two-panel card's box, shared verbatim by the loading skeleton and the\n * loaded card so neither can drift from the other.\n *\n * The height is applied per flow (`SCHEDULER_FLOW_PRESETS[flow].height`); what this adds is the\n * flex column that makes them work. On TABLET the height is stated for the\n * same reason the desktop one is: a CEILING only pins the tall stages. The short ones — the empty\n * month (\"No available times in September\"), a five-week month, the cold-start\n * skeleton — each settled at their own natural height, so paging a month or\n * waiting out a fetch resized the card under the visitor. Stating the height\n * makes every stage the same box and moves the variation inside, where the\n * times column already scrolls.\n *\n * 550, not the mock's 498: the mock draws one host and no timezone field, and\n * a real link has two hosts and a zone the visitor must be able to change.\n * That is ~55px of content the mock never budgeted for, and the month below it\n * is what would otherwise pay for it.\n *\n * The strip is `shrink-0`, so what is left goes to the slot row, which spends\n * it on a calendar sized to fit (no scroll) and a times list that scrolls on\n * its own.\n *\n * `md:flex md:flex-col` is what makes that possible at all. A stated height on\n * a plain block only clips (this card is `overflow-hidden` for its corners) —\n * it is the flex column plus the inner wrapper's `min-h-0` that lets the\n * content shrink into the height instead of being cut off by it.\n */\nconst CARD_CLASS = cn(\n  'overflow-hidden rounded-md border border-ods-border bg-ods-card',\n  'md:flex md:flex-col',\n  // The height is NOT baked in any more: it is chosen per flow at each render\n  // site (`cardClass`), because details-first needs a taller box and both\n  // constants are module scope.\n);\n\n/** Context strip over action panel, side by side from `lg`. `md:flex-1\n *  md:min-h-0` is the pair that makes it exactly as tall as the card states —\n *  grow into a stated height that content does not reach, shrink into one it\n *  overruns — so neither a short stage nor a long one changes the box. */\nconst CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-row';\n\n/** The two-line stages (load failure, \"booked on HubSpot\") — the SAME box as\n *  the booking card, because these are stages of one widget in one slot and a\n *  stage that collapses to two lines would snap the page shut under the\n *  visitor. Their content is centred in it and scrolls if a message ever\n *  outgrows it. */\nconst CARD_DEGRADED_CLASS = cn(\n  'flex flex-col items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]',\n  'md:justify-center md:overflow-y-auto',\n);\n\n/**\n * The two-line stages of the card (load failure, \"booked on HubSpot\", a host's\n * own \"calendar unavailable\") — ONE shape, exported so a host renders its\n * fallback in the same box instead of re-typing the chrome. The box is the\n * FLOW's: a host names the flow it would have mounted and the height follows\n * from {@link SCHEDULER_FLOW_PRESETS}, so the pairing is never re-derived.\n */\nexport function SchedulerDegradedCard({\n  flow = DEFAULT_SCHEDULER_FLOW,\n  className,\n  message,\n  action,\n  children,\n}: {\n  flow?: SchedulerFlow;\n  className?: string;\n  /** The one line the stage says, in the card's own type — so a host's fallback never picks a different scale. */\n  message?: string;\n  /** The one way out (the escape hatch, or a host's own link). */\n  action?: ReactNode;\n  children?: ReactNode;\n}) {\n  return (\n    <div className={cn(CARD_DEGRADED_CLASS, SCHEDULER_FLOW_PRESETS[flow].height, className)}>\n      {message && <p className=\"text-ods-text-secondary text-h6\">{message}</p>}\n      {action}\n      {children}\n    </div>\n  );\n}\n\n/**\n * Fail-closed gate: a link whose declared questions include an unsupported\n * type, or whose consent block is malformed, must NOT render a half-working\n * native form (a silently dropped required question or missing consent copy\n * is worse than no native form) — the escape hatch takes over.\n */\nfunction isNativelyBookable(availability: MeetingAvailability): boolean {\n  if (!availability.formFields.every(isSupportedFormField)) return false;\n  const consent = availability.legalConsent;\n  if (consent) {\n    if (typeof consent.processingConsentText !== 'string') return false;\n    if (!Array.isArray(consent.communicationConsentCheckboxes)) return false;\n  }\n  return true;\n}\n\nexport function HubSpotMeetingScheduler({\n  meetingId,\n  apiBaseUrl = '',\n  initialAvailability,\n  title,\n  description,\n  hosts,\n  displayTimezone,\n  fallbackUrl,\n  onBack,\n  onBooked,\n  className,\n  flow = DEFAULT_SCHEDULER_FLOW,\n  detailsForm: DetailsForm = BookingForm,\n  detailsFormProps,\n}: HubSpotMeetingSchedulerProps) {\n  const {\n    availability,\n    isLoadingAvailability,\n    isFetchingAvailability,\n    availabilityError,\n    monthOffset,\n    setMonthOffset,\n    refetchAvailability,\n    book,\n    isSubmitting,\n  } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });\n\n  const detailsFirst = flow === 'details-first';\n\n  // Zone resolution happens POST-mount (Intl) unless the embedder pins one —\n  // the initial (server) render stays deterministic; time labels appear once\n  // a zone exists.\n  //\n  // Priority, unchanged: the visitor's own pick (the panel's zone selector) >\n  // the embedder's `displayTimezone` > the browser's resolved zone. Only the\n  // last of the three is client-only, so `useIsHydrated` is the whole reason\n  // this could not be computed during render — with that gate stated directly,\n  // the zone is DERIVED and the effect that used to publish it (a setState in\n  // an effect body, and a dead render for every mount) is gone.\n  const hydrated = useIsHydrated();\n  const [pickedTimezone, setTimezone] = useState<string | null>(null);\n  const resolvedLocalTimezone = useMemo(() => {\n    if (!hydrated) return null;\n    try {\n      return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';\n    } catch {\n      return 'UTC';\n    }\n  }, [hydrated]);\n  const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;\n\n  const preset = SCHEDULER_FLOW_PRESETS[flow];\n  const [step, setStep] = useState<Step>(preset.initialStep);\n  // State, not a ref: the link-swap reset below writes it DURING RENDER, and\n  // this file's own rule forbids writing a ref there. \"Frozen\" means written\n  // once at Continue, not `useRef`.\n  const [stash, setStash] = useState<StashedDetails | null>(null);\n  /** Synchronous in-flight lock for the details-first slot click — see `onSelectSlot`. */\n  const inFlightRef = useRef(false);\n  /** details-first: a slot's POST is in flight. The ref covers the frame before\n   *  `isSubmitting` renders true, so every guard reads both. */\n  const postInFlight = useCallback(\n    () => detailsFirst && (isSubmitting || inFlightRef.current),\n    [detailsFirst, isSubmitting],\n  );\n\n  /**\n   * details-first step ONE is the form and nothing else — the mock\n   * (`4904:117130`) draws no context panel beside it, and paints the card on the\n   * page ground so the fields read as the raised elements. The panel (host,\n   * fixed length, timezone, and the Back edge to this form) belongs to the\n   * calendar step, exactly as slot-first has always drawn it.\n   */\n  const formOnly = detailsFirst && step === 'details';\n  const [pickedDurationMs, setDurationMs] = useState<number | null>(null);\n  const [pickedDay, setSelectedDay] = useState<string | null>(null);\n  const [selectedSlot, setSelectedSlot] = useState<number | null>(null);\n  const [confirmation, setConfirmation] = useState<BookingConfirmation | null>(null);\n  const [bookingError, setBookingError] = useState<MeetingBookingErrorCode | null>(null);\n\n  const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals();\n  const { toast } = useToast();\n\n  // Reset the machine when the host switches links. Adjusted while rendering —\n  // React's documented pattern for a prop-driven reset — so the swapped-in link\n  // never paints a frame of the previous link's chosen day, slot or\n  // confirmation. The ref counter stays in an effect: a ref must not be written\n  // during render.\n  const autoAdvanceCount = useRef(0);\n  const [machineFor, setMachineFor] = useState(meetingId);\n  if (machineFor !== meetingId) {\n    setMachineFor(meetingId);\n    setStep(preset.initialStep);\n    setDurationMs(null);\n    setSelectedDay(null);\n    setSelectedSlot(null);\n    setConfirmation(null);\n    setBookingError(null);\n    // Clearing the stash is load-bearing: otherwise the swapped-in link would\n    // POST the PREVIOUS link's answers, against a schema the server rebuilds\n    // from the new link's metadata.\n    setStash(null);\n  }\n  useEffect(() => {\n    autoAdvanceCount.current = 0;\n  }, [meetingId]);\n\n  // Memoised because the `??` fallback minted a NEW array on every\n  // render, which made the memo below re-run every time — i.e. do nothing.\n  const durations = useMemo(() => availability?.durationsMs ?? [], [availability?.durationsMs]);\n  const shownHosts = hosts ?? availability?.hosts ?? [];\n\n  // Default to the first offered duration — the visitor lands straight on\n  // the calendar; duration is a context-panel chip, not a wizard step.\n  // Derived, not stored: \"no explicit pick yet\" already means \"the first one\n  // offered\", so the effect that used to write that back into state was a\n  // second render pass for a value the first render could name — and the frame\n  // in between was the empty calendar this default exists to avoid.\n  const durationMs = pickedDurationMs ?? durations[0] ?? null;\n\n  const slots = useMemo(() => {\n    if (!availability || durationMs == null) return [];\n    return availability.slotsByDurationMs[String(durationMs)] ?? [];\n  }, [availability, durationMs]);\n\n  // Auto-select the first day WITH SLOTS IN THE VISIBLE MONTH (once the zone\n  // is known) so the visitor sees concrete times immediately — never a dead\n  // \"pick a day\" state, and NEVER a day from another month than the calendar\n  // shows (HubSpot's monthOffset payloads can carry near-term slots outside\n  // the requested month — the visible grid is the authority).\n  //\n  // Derived, with the visitor's own pick winning for as long as it is still one\n  // of the days on offer. Every input — the slots, the visible month, the zone\n  // — is available while rendering, so publishing this from an effect only\n  // guaranteed that each of those changes committed one dead \"pick a day\" frame\n  // before the calendar filled itself in.\n  const selectedDay = useMemo(() => {\n    if (step !== 'slot' || !timezone) return pickedDay;\n    const now = new Date();\n    const visible = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n    const monthPrefix = `${visible.getFullYear()}-${String(visible.getMonth() + 1).padStart(2, '0')}`;\n    const inMonth = slots.filter(ms => dayKeyInZone(ms, timezone).startsWith(monthPrefix));\n    if (inMonth.length === 0) {\n      // Nothing this month — a pick left over from a different month is not a\n      // selection in the grid the visitor is looking at.\n      return pickedDay && !pickedDay.startsWith(monthPrefix) ? null : pickedDay;\n    }\n    const dayKeys = new Set(inMonth.map(ms => dayKeyInZone(ms, timezone)));\n    if (pickedDay && dayKeys.has(pickedDay)) return pickedDay;\n    return dayKeyInZone(inMonth[0], timezone);\n  }, [step, timezone, slots, pickedDay, monthOffset]);\n\n  // Fully-booked month with more ahead → auto-advance (bounded, ≤3 hops)\n  // before showing the empty state.\n  useEffect(() => {\n    if (\n      step === 'slot' &&\n      availability &&\n      !isFetchingAvailability &&\n      durationMs != null &&\n      slots.length === 0 &&\n      availability.hasMore &&\n      autoAdvanceCount.current < 3 &&\n      // Not while a chip's POST is in flight — paging would unmount it.\n      !postInFlight()\n    ) {\n      autoAdvanceCount.current += 1;\n      setMonthOffset(monthOffset + 1);\n    }\n  }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset, postInFlight]);\n\n  /** The back edge's destination from the form: the calendar, with any\n   *  submit error cleared so the visitor doesn't carry it back. */\n  const backToSlot = useCallback(() => {\n    setStep('slot');\n    setBookingError(null);\n  }, []);\n\n  /** details-first's inverse: back from the CALENDAR to the form. The stash is\n   *  what repopulates it, so nothing is cleared here but the error. */\n  const backToDetails = useCallback(() => {\n    // Not while a slot's POST is in flight — the step must stay on the\n    // calendar the spinner lives on until the result routes it.\n    if (postInFlight()) return;\n    // No slot rides back to the form: in this flow the form never legitimately\n    // holds one (the slot IS the submit), and a chip the server rejected with a\n    // slot-step toast would otherwise print in the form's summary line.\n    setSelectedSlot(null);\n    setStep('details');\n    setBookingError(null);\n  }, [postInFlight]);\n\n  /**\n   * details-first: Continue does not POST. It validates, freezes the answers\n   * and the humanity signals (captured HERE, while the form and its honeypot\n   * are still mounted — `getSignals()` reads a detached ref once they unmount),\n   * and hands over to the calendar.\n   */\n  const stashDetails = useCallback(\n    (payload: Record<string, unknown>): Promise<void> => {\n      setBookingError(null);\n      setStash({ payload, signals: getSignals() });\n      setStep('slot');\n      // The visitor has been filling a form; the month they are about to see\n      // may have moved on, and its hop budget should start fresh.\n      autoAdvanceCount.current = 0;\n      void refetchAvailability();\n      // Deliberately NOT `async`: nothing here is awaited. The refetch is\n      // fire-and-forget so the calendar paints from cache the moment the step\n      // flips, and awaiting it would hold `BookingForm`'s isSubmitting past\n      // the unmount. `onSubmit` is Promise-returning, hence the explicit\n      // resolve rather than an empty async body (which `require-await` flags).\n      return Promise.resolve();\n    },\n    [getSignals, refetchAvailability],\n  );\n\n  const handleSubmit = useCallback(\n    async (payload: Record<string, unknown>) => {\n      // Captured BEFORE the clear below: `book()` reports a re-entrant call as\n      // a VALIDATION code carrying `BOOKING_IN_FLIGHT_MESSAGE`, and by\n      // then this clear has already run — so a double-click would wipe a\n      // visible SLOT_TAKEN alert on its way to being ignored.\n      const priorError = bookingError;\n      setBookingError(null);\n      const result = await book(payload);\n\n      // Not an error: the hook's in-flight guard. No toast, no step change.\n      if (!result.ok && result.message === BOOKING_IN_FLIGHT_MESSAGE) {\n        setBookingError(priorError);\n        return;\n      }\n      if (result.ok && result.confirmation) {\n        setConfirmation(result.confirmation);\n        setStep('confirmed');\n        onBooked?.(result.confirmation);\n        return;\n      }\n      const code = result.code ?? 'TEMPORARILY_UNAVAILABLE';\n      setBookingError(code);\n      if (code === 'SLOT_TAKEN') {\n        // Recover: refresh the grid (the slot vanishes) and reset the timing\n        // signal so the retry isn't flagged too-fast.\n        setSelectedSlot(null);\n        setStep('slot');\n        // NOT in details-first: the retry re-POSTs the frozen stash, so\n        // `getSignals()` is never consulted again — but `resetSignals()` also\n        // re-stamps `startedAtRef`, which would make a LATER Continue (after a\n        // VALIDATION round-trip) read as too-fast against the min-fill floor.\n        if (!detailsFirst) resetSignals();\n        void refetchAvailability();\n      } else {\n        // details-first: the chip that submitted is un-picked on EVERY failure —\n        // selection means submit in this flow, so a rejected chip must not sit\n        // in the selected variant (and the form's summary must never show a\n        // slot the server did not accept).\n        if (detailsFirst) setSelectedSlot(null);\n        if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {\n          // DETAILS errors: the form is unmounted by now, so send the visitor\n          // back to it (the stash repopulates every answer).\n          setStep('details');\n        }\n        // The error surface is the TOAST, full stop (host-mounted Toaster —\n        // every hub platform mounts it globally; embedders must too). A silent\n        // return to an unchanged form would read as nothing having happened.\n        toast({\n          variant: 'error',\n          title: 'Booking failed',\n          description: BOOKING_ERROR_COPY[code] ?? BOOKING_ERROR_COPY.VALIDATION,\n        });\n      }\n    },\n    [book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],\n  );\n\n  const escapeHatch = fallbackUrl ? (\n    <Button variant=\"outline\" size=\"small-legacy\" href={fallbackUrl} openInNewTab>\n      Open in HubSpot\n    </Button>\n  ) : null;\n\n  // ---- terminal / degraded states -----------------------------------------\n\n  /** The card's ONE back edge, per flow and step — the skeleton and the loaded\n   *  panel wire the same one, so a month load can never swap it for the host's\n   *  exit under a details-first visitor. */\n  const backEdge = detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack;\n\n  /**\n   * The card states ONE height and everything inside derives from it (see\n   * `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it\n   * gets its own fixed pair rather than a floor — a floor was tried here before\n   * and let the tall stage push the wrapper.\n   *\n   * Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and\n   * last-wins, so appending it after would make a host's own `h-*` unreachable,\n   * which is the override the height-inside-CARD_CLASS arrangement allows today.\n   */\n  const cardClass = cn(CARD_CLASS, preset.height, detailsFirst && 'flex flex-col', formOnly && 'bg-ods-bg', className);\n  /** details-first states a height on phones too (see MEETING_SCHEDULER_DETAILS_FIRST_H), so\n   *  the wrappers that let content shrink into a stated height run at every width here. */\n  const innerClass = cn(CARD_INNER_CLASS, detailsFirst && 'min-h-0 flex-1');\n  const actionPanelClass = cn(ACTION_PANEL_CLASS, detailsFirst && 'min-h-0 overflow-y-auto');\n  const stepPanelClass = cn(STEP_PANEL_CLASS, detailsFirst && 'min-h-0 overflow-y-auto');\n\n  /** One card SHAPE for every degraded return below. */\n  const degraded = (message: string) => (\n    <SchedulerDegradedCard flow={flow} className={className} message={message} action={escapeHatch} />\n  );\n  const degradedCard = degraded(\"We couldn't load available call times. Please try again shortly.\");\n\n  if (isLoadingAvailability && !availability) {\n    // COLD start only — a month already in the query cache renders straight\n    // into the loaded card. Composed inside the SAME wrappers as that card\n    // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing\n    // shifts when it swaps.\n    return (\n      <div className={cardClass}>\n        <div className={innerClass}>\n          {!formOnly && <ContextPanelSkeleton onBack={backEdge} className={CONTEXT_PANEL_CLASS} />}\n          <div className={actionPanelClass}>\n            {/* Both terms matter. Without `flow` this stays a calendar where\n                the form belongs; without the step term, paging a month AFTER\n                Continue (which happens at `step === 'slot'`) would swap the\n                calendar out for a form skeleton and back. */}\n            {formOnly ? (\n              // The SAME wrapper the loaded form gets, so the skeleton never\n              // states its own inset.\n              <div className={stepPanelClass}>\n                <BookingFormSkeleton\n                  fieldRows={detailsFormProps?.fieldRows}\n                  consent={Boolean(detailsFormProps?.consent)}\n                  footerNote={preset.footerNote}\n                />\n              </div>\n            ) : (\n              <SlotPickerSkeleton monthOffset={monthOffset} />\n            )}\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (availabilityError || !availability) return degradedCard;\n\n  if (!isNativelyBookable(availability)) {\n    // Fail closed — never render a half-working native form on a link with\n    // questions or consent we can't faithfully reproduce.\n    return degraded('This meeting type is booked directly on HubSpot.');\n  }\n\n  // ---- the card ------------------------------------------------------------\n\n  return (\n    <div className={cardClass}>\n      <div className={innerClass}>\n        {!formOnly && (\n          <SchedulerContextPanel\n            hosts={shownHosts}\n            title={title}\n            description={description}\n            durationsMs={durations}\n            selectedDurationMs={durationMs}\n            onSelectDuration={ms => {\n              setDurationMs(ms);\n              setSelectedDay(null);\n              setSelectedSlot(null);\n              // slot-first only. In details-first `details` is step ONE, so this\n              // would yank a mid-typing visitor onto the calendar and unmount the\n              // form under them.\n              if (!detailsFirst && step === 'details') setStep('slot');\n            }}\n            timezone={timezone}\n            onTimezoneChange={setTimezone}\n            // ONE back edge for the whole card, in the panel that never swaps.\n            // Its destination is simply \"the previous step\": from the form back\n            // to the calendar, from the calendar out of the scheduler (if the\n            // host gave us an exit at all). Two Backs on screen reading the same\n            // word with different destinations was the ambiguity the details\n            // step used to carry.\n            // details-first inverts the mapping: Back at `slot` returns to the\n            // form. At `details` the form-only layout mounts no panel, so a host\n            // exit is not rendered there (the page around the card is the exit).\n            onBack={backEdge}\n            // `locked` HIDES the selectors (post-selection steps); `disabled`\n            // keeps them mounted but inert. details-first needs the second while\n            // a POST is in flight: a mid-flight change would clear the slot\n            // under the spinner while the body already built carries the old\n            // duration — and unmounting the row would shift the grid under it.\n            locked={detailsFirst ? step === 'confirmed' : step !== 'slot'}\n            disabled={detailsFirst && isSubmitting}\n            // The zone still governs every time on screen — including the\n            // summary line on the form — so the picker stays until there is\n            // nothing left to re-read in it.\n            showTimezone={step !== 'confirmed'}\n            className={CONTEXT_PANEL_CLASS}\n          />\n        )}\n\n        {/* Scrolls INSIDE the fixed card rather than growing it — see\n            MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's\n            default `min-height:auto` refuses to shrink below its content. */}\n        <div className={actionPanelClass}>\n          {step === 'confirmed' && confirmation && timezone ? (\n            <div className={stepPanelClass}>\n              <Confirmation confirmation={confirmation} timezone={timezone} />\n            </div>\n          ) : step === 'details' &&\n            (detailsFirst\n              ? // The form is step ONE here: no slot yet, and `timezone` is null\n                // on the server render, so neither may gate the PANEL. A link\n                // publishing no durations has no form worth showing; it gets the\n                // \"nothing published\" message below at every moment, refetch or\n                // not — never a calendar skeleton in the form-only layout.\n                durations.length > 0\n              : durationMs != null && selectedSlot != null && timezone) ? (\n            <div className={cn(stepPanelClass, 'gap-[var(--spacing-system-m)]')}>\n              {/* Top-aligned, and no back edge of its own: the ONE back edge\n                  lives in the context panel at every step (the `onBack` wired on `SchedulerContextPanel`),\n                  which is where the design puts it and the only spot that\n                  stays put while this side swaps between calendar and form.\n\n                  The summary keeps its OWN guard — including `timezone`, which\n                  is null pre-hydration and which `Intl.DateTimeFormat` throws a\n                  RangeError on rather than ignoring. */}\n              {selectedSlot != null && durationMs != null && timezone != null && (\n                <p className=\"text-ods-text-primary text-h4\">\n                  {new Intl.DateTimeFormat(undefined, {\n                    timeZone: timezone,\n                    weekday: 'long',\n                    month: 'long',\n                    day: 'numeric',\n                    hour: 'numeric',\n                    minute: '2-digit',\n                    timeZoneName: 'short',\n                  }).format(new Date(selectedSlot))}{' '}\n                  · {formatDurationCompact(durationMs / 1000)}\n                </p>\n              )}\n              <DetailsForm\n                {...detailsFormProps}\n                availability={availability}\n                meetingId={meetingId}\n                startTimeMs={selectedSlot ?? undefined}\n                durationMs={durationMs ?? undefined}\n                timezone={timezone}\n                deferSlot={detailsFirst}\n                submitLabel={preset.submitLabel}\n                footerNote={preset.footerNote}\n                initialValues={detailsFirst ? stash?.payload : undefined}\n                isSubmitting={isSubmitting}\n                onSubmit={detailsFirst ? stashDetails : handleSubmit}\n                honeypotInputProps={honeypotInputProps}\n                getSignals={getSignals}\n              />\n            </div>\n          ) : (\n            <div className=\"flex flex-col gap-[var(--spacing-system-m)] md:min-h-0 md:flex-1\">\n              {bookingError === 'SLOT_TAKEN' && (\n                <Alert\n                  variant=\"warning\"\n                  className=\"mx-[var(--spacing-system-l)] mt-[var(--spacing-system-l)] w-auto lg:m-0 lg:w-full\"\n                >\n                  <AlertDescription>\n                    That time was just taken — pick another slot below. (If you already submitted, check your email for\n                    a confirmation before rebooking.)\n                  </AlertDescription>\n                </Alert>\n              )}\n              {timezone && durationMs != null ? (\n                // The picker owns BOTH the loading and the empty month now —\n                // it keeps the calendar up either way, so there is no branch\n                // here that can take it off screen mid-flow.\n                <SlotPicker\n                  slots={slots}\n                  timezone={timezone}\n                  monthOffset={monthOffset}\n                  onMonthOffsetChange={o => {\n                    // details-first: a POST is in flight from this grid; paging\n                    // would clear the spinning chip and swap the key under it.\n                    if (postInFlight()) return;\n                    setSelectedDay(null);\n                    setSelectedSlot(null);\n                    setMonthOffset(o);\n                  }}\n                  selectedSlot={selectedSlot}\n                  onSelectSlot={ms => {\n                    if (!detailsFirst) {\n                      setSelectedSlot(ms);\n                      setStep('details');\n                      return;\n                    }\n                    // details-first: the slot IS the submit. The step stays\n                    // 'slot' for the whole POST — moving it here would unmount\n                    // the chip the spinner lives on and fight the SLOT_TAKEN\n                    // branch, which puts the visitor back on this calendar.\n                    // `isSubmitting` is the render's snapshot; a second chip\n                    // clicked in the same frame sees it false. The ref is the\n                    // synchronous lock, so the second click changes nothing.\n                    if (postInFlight() || !stash) return;\n                    inFlightRef.current = true;\n                    setSelectedSlot(ms);\n                    void handleSubmit({\n                      ...stash.payload,\n                      ...stash.signals,\n                      meetingId,\n                      // `ms`, NOT `selectedSlot` — the setState above has not\n                      // committed inside this handler, so state holds null on\n                      // the first booking and the PREVIOUS slot on a retry.\n                      startTimeMs: ms,\n                      // Live values: both stay editable after Continue, so the\n                      // copies frozen at that point can be stale.\n                      durationMs,\n                      timezone,\n                    }).finally(() => {\n                      inFlightRef.current = false;\n                    });\n                  }}\n                  selectedDay={selectedDay}\n                  onSelectDay={day => {\n                    // Same lock: another day would swap the times column out\n                    // from under the spinning chip.\n                    if (postInFlight()) return;\n                    setSelectedDay(day);\n                  }}\n                  isLoading={isLoadingAvailability}\n                  // Distinct from `isLoading`, which swaps the whole times\n                  // column for a skeleton: the grid must stay up with the\n                  // clicked chip spinning.\n                  isSubmitting={detailsFirst ? isSubmitting : undefined}\n                />\n              ) : durations.length === 0 && (!isFetchingAvailability || formOnly) ? (\n                // The LINK publishes nothing at all — a different thing from a\n                // fully-booked month, and the only case the escape hatch is\n                // the right answer to. In the form-only layout it holds through\n                // a refetch too: the alternative frame is a calendar skeleton\n                // where a form belongs.\n                <div className=\"flex flex-col items-start gap-[var(--spacing-system-m)]\">\n                  <p className=\"text-ods-text-secondary text-h6\">No call times are published right now.</p>\n                  {escapeHatch}\n                </div>\n              ) : (\n                // Pre-mount timezone-resolution window (every SSR load hits\n                // this for one frame-batch) — same layout, real calendar, so\n                // nothing shifts when the picker takes over.\n                <SlotPickerSkeleton monthOffset={monthOffset} />\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// The override surface: a host builds its `detailsForm` by re-rendering\n// `BookingForm` with `fieldRows`, so it needs both from this entry point (the\n// package exports `./components/meeting-scheduler`, not the file beneath it).\nexport {\n  BookingForm,\n  BookingFormSkeleton,\n  type BookingFormProps,\n  type BookingFieldRow,\n  type BookingFieldSlot,\n  type BookingFormConsent,\n} from './booking-form';\n\nexport {\n  MeetingSchedulerDirectory,\n  MeetingSchedulerDirectoryRowSkeleton,\n  type MeetingSchedulerDirectoryProps,\n} from './directory';\nexport type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost };\nexport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';\n","'use client';\n\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useLayoutEffect, useRef, useState } from 'react';\nimport {\n  MEETING_BOOKING_ERROR_CODES,\n  type BookingConfirmation,\n  type MeetingAvailability,\n  type MeetingBookingErrorCode,\n} from '../schemas/meeting-booking-schema';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention';\n\n/**\n * useMeetingBooking — data layer for `<HubSpotMeetingScheduler>` (the widget\n * files never inline fetch; same split as `useContactSubmission`/ContactForm).\n *\n * REQUIRES a `QueryClientProvider` in the host, like every other query-backed\n * surface in this lib (tickets, chat, onboarding-guides). `@tanstack/react-query`\n * is a declared peer dependency; the lib never mounts a provider of its own.\n *\n * Availability is a per-month GET, cached per (host, link, month). The cache is\n * the point: paging back to a month already seen is INSTANT and silent, which\n * is what a hand-rolled `useEffect` + `useState` could not do — it re-fetched\n * and re-skeletoned the same month every time the visitor stepped back.\n *\n * Deliberately NOT `placeholderData: keepPreviousData`. The payload is\n * month-scoped, so last month's slots under this month's caption describe days\n * that aren't on screen — a grid that lights up the wrong dates is worse than\n * one that is briefly inert. What kills the flicker instead is WHERE the\n * loading state lands: the calendar is fully derivable from the date alone, so\n * it keeps rendering (with nothing selectable yet) while only the times column\n * — the part that genuinely has no answer — shows a placeholder.\n *\n * `staleTime` is ZERO: the host serves availability live (no-store), and a\n * cached month re-shown while paging back is organizer-visible staleness. A\n * slot gone by the time it is clicked costs a booking — always refetch.\n *\n * Booking: a mutation, guarded so a second submit can't start while one is in\n * flight (the transport never retries POST — this is the only double-booking\n * guard). Errors surface as typed `MeetingBookingErrorCode`s the widget keys\n * its recovery UI off, never as thrown rejections.\n *\n * All requests go through the host proxy (`apiBaseUrl` prefix, FaqSection\n * precedent) via `contentFetch`, so embedded hosts with an embed-auth adapter\n * inherit auth with zero extra wiring.\n */\n\nexport interface BookingResult {\n  ok: boolean;\n  confirmation?: BookingConfirmation;\n  code?: MeetingBookingErrorCode;\n  message?: string;\n}\n\n/** Always refetch — availability is served live (no-store) by the host; a\n *  cached month shown while paging back is exactly the staleness the server\n *  layers were stripped to kill (2026-08-27). */\nconst AVAILABILITY_STALE_MS = 0;\n\n/** Query key for one month of one link. Exported so a host can prefetch or\n *  invalidate a month it knows changed (e.g. after booking elsewhere). */\nexport function meetingAvailabilityKey(apiBaseUrl: string, meetingId: string, monthOffset: number) {\n  return ['meeting-availability', apiBaseUrl, meetingId, monthOffset] as const;\n}\n\n/** The message `book()` returns for a re-entrant call — a sentinel, not an error; the scheduler matches on it. */\nexport const BOOKING_IN_FLIGHT_MESSAGE = 'Already submitting';\n\nexport function useMeetingBooking(options: {\n  meetingId: string;\n  apiBaseUrl?: string;\n  initialAvailability?: MeetingAvailability;\n}) {\n  const { meetingId, apiBaseUrl = '', initialAvailability } = options;\n  const queryClient = useQueryClient();\n\n  const seededOffset = initialAvailability?.meetingId === meetingId ? initialAvailability.monthOffset : undefined;\n  const [monthOffset, setMonthOffsetState] = useState(seededOffset ?? 0);\n\n  const setMonthOffset = useCallback((offset: number) => {\n    setMonthOffsetState(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n  }, []);\n\n  // Link switch resets paging — without this, the new link is queried at the\n  // PREVIOUS link's month. Layout effect so the query below sees the reset\n  // offset in the same commit.\n  const prevMeetingIdRef = useRef(meetingId);\n  useLayoutEffect(() => {\n    if (prevMeetingIdRef.current === meetingId) return;\n    prevMeetingIdRef.current = meetingId;\n    setMonthOffsetState(0);\n  }, [meetingId]);\n\n  // The SSR seed covers exactly one (link, month) cell of the cache.\n  const seed =\n    initialAvailability?.meetingId === meetingId && initialAvailability.monthOffset === monthOffset\n      ? initialAvailability\n      : undefined;\n\n  const availabilityQuery = useQuery({\n    queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset),\n    queryFn: async ({ signal }) => {\n      const params = new URLSearchParams({ meeting: meetingId, monthOffset: String(monthOffset) });\n      const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`, { signal });\n      if (!res.ok) throw new Error(`availability ${res.status}`);\n      return (await res.json()) as MeetingAvailability;\n    },\n    staleTime: AVAILABILITY_STALE_MS,\n    initialData: seed,\n    // Stamped as infinitely old on purpose: the seed is first-paint\n    // scaffolding for a 60-second-volatile resource, not truth, so it paints\n    // immediately AND is refetched immediately.\n    initialDataUpdatedAt: seed ? 0 : undefined,\n  });\n\n  const refetchAvailability = useCallback(async () => {\n    // Recovery path (a slot taken from under the visitor): drop the cached\n    // month so the grid reloads from the network, not from the copy that\n    // still lists the taken slot. Best-effort — a failure leaves the stale\n    // grid up rather than blanking the card.\n    await queryClient\n      .invalidateQueries({ queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset) })\n      .catch(() => undefined);\n  }, [queryClient, apiBaseUrl, meetingId, monthOffset]);\n\n  const bookMutation = useMutation<BookingResult, never, Record<string, unknown>>({\n    mutationFn: async payload => {\n      try {\n        const res = await contentFetch(`${apiBaseUrl}/api/meetings/book`, {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        });\n        const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;\n        if (res.ok) return { ok: true, confirmation: data as unknown as BookingConfirmation };\n        const rawCode = typeof data.code === 'string' ? data.code : '';\n        const code = (MEETING_BOOKING_ERROR_CODES as readonly string[]).includes(rawCode)\n          ? (rawCode as MeetingBookingErrorCode)\n          : 'TEMPORARILY_UNAVAILABLE';\n        return { ok: false, code, message: typeof data.error === 'string' ? data.error : undefined };\n      } catch {\n        return { ok: false, code: 'TEMPORARILY_UNAVAILABLE' };\n      }\n    },\n    // A booking is not a read — never retried, at any layer.\n    retry: false,\n  });\n\n  // `isPending` alone would not stop a second submit dispatched in the same\n  // tick as the first; the ref closes that window the way the pre-query\n  // implementation did.\n  // Holds the identity of the submit that owns the lock (`null` = idle) rather\n  // than a bare boolean, so the release below — which runs after an await — can\n  // check that the lock it clears is still its own rather than clearing\n  // whatever happens to be there when the booking settles.\n  const submittingRef = useRef<object | null>(null);\n  const { mutateAsync } = bookMutation;\n  const book = useCallback(\n    async (payload: Record<string, unknown>): Promise<BookingResult> => {\n      if (submittingRef.current !== null) return { ok: false, code: 'VALIDATION', message: BOOKING_IN_FLIGHT_MESSAGE };\n      const submitToken: object = {};\n      submittingRef.current = submitToken;\n      try {\n        return await mutateAsync(payload);\n      } finally {\n        if (submittingRef.current === submitToken) submittingRef.current = null;\n      }\n    },\n    [mutateAsync],\n  );\n\n  return {\n    availability: availabilityQuery.data ?? null,\n    /** No availability for this month yet — the times column has nothing to show. */\n    isLoadingAvailability: availabilityQuery.isPending,\n    /** ANY request in flight, including a silent revalidation of cached data. */\n    isFetchingAvailability: availabilityQuery.isFetching,\n    availabilityError: availabilityQuery.error ? availabilityQuery.error.message : null,\n    monthOffset,\n    setMonthOffset,\n    refetchAvailability,\n    book,\n    isSubmitting: bookMutation.isPending,\n  } as const;\n}\n","import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n  meetingId: string;\n  monthOffset: number;\n  hasMore: boolean;\n  /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n  durationsMs: number[];\n  /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n  slotsByDurationMs: Record<string, number[]>;\n  formFields: MeetingFormField[];\n  /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n  legalConsent: MeetingLegalConsent | null;\n  /**\n   * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n   * builds from its own people data (e.g. a profiles table matched\n   * server-side). NEVER carries emails or busy-time data; optional so\n   * existing hosts stay wire-compatible.\n   */\n  hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n  name: string;\n  avatarUrl: string | null;\n  /** Job title / role line under the name (null → omitted). */\n  title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n  id: string;\n  /** The link's public HubSpot booking URL — escape-hatch target only. */\n  link: string;\n  /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n  slug: string;\n  /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n  purpose: string;\n  title: string;\n  description: string | null;\n  kind: 'personal' | 'team';\n  /** Display-only minutes projection (booking stays ms end-to-end). */\n  durationsMinutes: number[];\n  hosts: MeetingHost[];\n  /** Earliest bookable slot (epoch ms) from the current-month payload. */\n  nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n  purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n  fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n  name: string;\n  label: string;\n  type: string;\n  required: boolean;\n  options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n  processingConsentText: string;\n  processingConsentCheckboxLabel: string | null;\n  communicationConsentText: string | null;\n  communicationConsentCheckboxes: Array<{\n    communicationTypeId: string;\n    label: string;\n    required: boolean;\n  }>;\n  privacyPolicyText: string | null;\n  isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n  meetingId: string;\n  title: string;\n  startTimeMs: number;\n  durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n  'SLOT_TAKEN',\n  'VALIDATION',\n  'INVALID_EMAIL',\n  'LINK_GONE',\n  'TEMPORARILY_UNAVAILABLE',\n  'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * THE registry of HubSpot question types the native form supports — one entry\n * per `fieldType`, and the ONLY place a type is declared. Everything else\n * derives from it: `SupportedFormFieldType` is its key union,\n * `SUPPORTED_FORM_FIELD_TYPES` its keys, `FORM_FIELD_TYPES_WITH_OPTIONS` the\n * entries that carry `options`, and `makeBookingSchema` maps each answer\n * through the entry's validator. The widget's control table\n * (`booking-form.tsx`) is a `Record` over the same key union, so a type added\n * here without a control is a COMPILE error, not a silent gap.\n *\n * Fail-closed: a `fieldType` with no entry makes the link \"not natively\n * bookable\" and the card falls back to the HubSpot escape hatch.\n *\n * Every string type rides the wire as a STRING (HubSpot's book endpoint takes\n * `{ name, value: string }`); `checkbox` is the one boolean. `number` is a\n * Number property validated as a decimal literal — what `<input type=\"number\">`\n * emits and what the property stores.\n */\nexport interface FormFieldTypeSpec {\n  /** The answer's wire shape, which also decides how required/optional wraps it. */\n  kind: 'string' | 'boolean';\n  /** Whether HubSpot publishes `options` for the type (the pickers). */\n  hasOptions: boolean;\n  /**\n   * The validator for ONE answer. String types EXTEND `base`, which already\n   * carries the required-ness (`min(1)` when required) — so \"X is required\" is\n   * the first issue reported for an empty answer, ahead of the type's own rule.\n   * Boolean types ignore it.\n   */\n  validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;\n  /** The control's placeholder, derived from the field (the mock's \"Enter Company Name\"). */\n  placeholder?: (field: MeetingFormField) => string;\n}\n\nconst optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>\n  base.refine(v => !v || (field.options ?? []).includes(v), {\n    message: `Please choose a valid option for ${field.label}`,\n  });\n\n/** The wire shape of a number answer — what the form canonicalises TO and the validator checks. */\nexport const DECIMAL_LITERAL_RE = /^-?\\d+(\\.\\d+)?$/;\n\nexport const FORM_FIELD_TYPES = {\n  text: {\n    kind: 'string',\n    hasOptions: false,\n    validator: (field, base) => base.max(1000, { message: `${field.label} is too long` }),\n    placeholder: field => `Enter ${field.label}`,\n  },\n  textarea: {\n    kind: 'string',\n    hasOptions: false,\n    validator: (field, base) => base.max(5000, { message: `${field.label} is too long` }),\n    placeholder: field => `Enter text${field.required ? '' : ' (optional)'}`,\n  },\n  number: {\n    kind: 'string',\n    hasOptions: false,\n    // Canonical decimal literal — the control normalises what the browser\n    // accepts (`1e3`, `007`) to this before it is validated or sent.\n    validator: (field, base) =>\n      base\n        .max(32, { message: `${field.label} is too long` })\n        .regex(DECIMAL_LITERAL_RE, { message: `${field.label} must be a number` }),\n  },\n  select: { kind: 'string', hasOptions: true, validator: optionValidator },\n  radio: { kind: 'string', hasOptions: true, validator: optionValidator },\n  checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },\n} as const satisfies Record<string, FormFieldTypeSpec>;\n\nexport type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;\n\nexport const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];\n\n/** The types whose `options` the host must forward (select, radio). */\nexport const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(\n  type => FORM_FIELD_TYPES[type].hasOptions,\n);\n\n/** Whether a raw HubSpot field type is one whose answers come from declared options. */\nexport function formFieldTypeHasOptions(type: string): boolean {\n  return (FORM_FIELD_TYPES_WITH_OPTIONS as readonly string[]).includes(type);\n}\n\nexport function isSupportedFormField(field: MeetingFormField): field is SupportedMeetingFormField {\n  return Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, field.type);\n}\n\n/** A declared question whose `type` is in the registry. */\nexport type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType };\n\n/**\n * The scheduler's fixed identity fields. HubSpot's book endpoint takes them\n * TOP-LEVEL (`firstName`, `lastName`, `email`), its own booking page hardcodes\n * them, and the link's `formFields` never lists them — so they are data HERE,\n * rendered by the widget through the SAME control path as every declared\n * question, rather than three hand-written blocks. `inputType`/`autoComplete`\n * are the browser hints a text control takes; the wire and the validator do\n * not see them.\n */\nexport interface BuiltInBookingField extends MeetingFormField {\n  type: 'text';\n  required: true;\n  inputType?: 'email';\n  autoComplete: string;\n  /** Own placeholder. Omitted, the control derives one from the label (the registry's rule). */\n  placeholder?: string;\n  /** The wire's own required/format message — kept verbatim from the schema it replaced. */\n  requiredMessage: string;\n}\n\nexport const BUILT_IN_BOOKING_FIELDS = [\n  {\n    name: 'firstName',\n    label: 'First Name',\n    type: 'text',\n    required: true,\n    autoComplete: 'given-name',\n    requiredMessage: 'First name is required',\n  },\n  {\n    name: 'lastName',\n    label: 'Last Name',\n    type: 'text',\n    required: true,\n    autoComplete: 'family-name',\n    requiredMessage: 'Last name is required',\n  },\n  {\n    name: 'email',\n    label: 'Email',\n    type: 'text',\n    required: true,\n    inputType: 'email',\n    autoComplete: 'email',\n    placeholder: 'username@mail.com',\n    requiredMessage: 'Please enter a valid email address',\n  },\n] as const satisfies readonly BuiltInBookingField[];\n\n/** `'firstName' | 'lastName' | 'email'` — derived from the array, never restated. */\nexport type BuiltInBookingFieldName = (typeof BUILT_IN_BOOKING_FIELDS)[number]['name'];\n\n/** The registry entry for a supported type, widened to the spec so optional\n *  members (`placeholder`) are readable without narrowing on the union. */\nexport function fieldTypeSpec(type: SupportedFormFieldType): FormFieldTypeSpec {\n  return FORM_FIELD_TYPES[type];\n}\n\n/** The wire validator for one identity field, from ITS declaration above. */\nfunction identityValidator(name: BuiltInBookingFieldName) {\n  const field: BuiltInBookingField | undefined = BUILT_IN_BOOKING_FIELDS.find(f => f.name === name);\n  if (!field) throw new Error(`Unknown built-in booking field: ${name}`);\n  // The field's own message FIRST: an empty value must fail `min`/`email` with\n  // the wire's copy, not a length cap it cannot have hit.\n  const tooLong = { message: `${field.label} is too long` };\n  return field.inputType === 'email'\n    ? z.string().email({ message: field.requiredMessage }).max(255, tooLong)\n    : z.string().min(1, { message: field.requiredMessage }).max(255, tooLong);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n  if (!IANA_TZ_RE.test(tz)) return false;\n  try {\n    new Intl.DateTimeFormat('en-US', { timeZone: tz });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n  try {\n    return Intl.getCanonicalLocales(locale).length > 0;\n  } catch {\n    return false;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nfunction buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.ZodTypeAny, TZone extends z.ZodTypeAny>(\n  formFields: MeetingFormField[],\n  legalConsent: MeetingLegalConsent | null,\n  // Passed IN rather than switched on a boolean: a `deferSlot: boolean`\n  // parameter cannot be narrowed at type level, so every ternary inside would\n  // infer a union and widen `startTimeMs`/`durationMs` to `number | null |\n  // undefined` for BOTH schemas — including the server's wire type.\n  slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },\n) {\n  const answers: Record<string, z.ZodTypeAny> = {};\n  for (const field of formFields) {\n    if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n    const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];\n    // required/optional wrapping follows the answer's wire KIND, not its type:\n    // a boolean is true-or-absent, a string is non-empty-or-empty. For strings\n    // the required check is the FIRST rule on the chain, so an empty answer\n    // reports \"is required\" rather than the type's own message.\n    let validator: z.ZodTypeAny;\n    if (spec.kind === 'boolean') {\n      validator = field.required ? z.literal(true, { message: `${field.label} is required` }) : z.boolean().optional();\n    } else {\n      const base = field.required ? z.string().min(1, { message: `${field.label} is required` }) : z.string();\n      validator = spec.validator(field, base);\n      if (!field.required) validator = validator.optional().or(z.literal(''));\n    }\n    answers[field.name] = validator;\n  }\n\n  const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n    .filter(c => c.required)\n    .map(c => c.communicationTypeId);\n\n  // A required answer cannot be enforced by an `.optional()` parent object —\n  // omitting the `formFields` key entirely would skip every per-question\n  // rule. When the link declares at least one required supported question,\n  // the object itself is required.\n  const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n  const answersObject = z.object(answers);\n\n  return (\n    z\n      .object({\n        meetingId: z.string().min(1),\n        startTimeMs: slot.startTimeMs,\n        durationMs: slot.durationMs,\n        // The identity trio's rules come from BUILT_IN_BOOKING_FIELDS — the\n        // keys stay literal so the payload type keeps its named properties.\n        firstName: identityValidator('firstName'),\n        lastName: identityValidator('lastName'),\n        email: identityValidator('email'),\n        timezone: slot.timezone,\n        locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n        // Plain `.optional()` (no `.default()`) so zod's input and output types\n        // match — react-hook-form's zodResolver needs them identical, and the\n        // server handles `undefined` explicitly anyway. REQUIRED when the link\n        // declares required questions (see `hasRequiredAnswers` above).\n        formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n        legalConsentResponses: z\n          .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n          .optional(),\n      })\n      // Object-level rule: `.refine` on an `.optional()` field is skipped when\n      // the field is absent — required consents must reject even on a payload\n      // that omits the array entirely.\n      .superRefine((data, ctx) => {\n        const responses = data.legalConsentResponses ?? [];\n        const ok = requiredConsentIds.every(id =>\n          responses.some(r => r.communicationTypeId === id && r.consented === true),\n        );\n        if (!ok) {\n          ctx.addIssue({\n            code: z.ZodIssueCode.custom,\n            path: ['legalConsentResponses'],\n            message: 'Required consent checkboxes must be accepted',\n          });\n        }\n      })\n  );\n}\n\n/** The slot's wire validators — ONE declaration for the strict and the deferred schema. */\nconst SLOT_INSTANT = z.number().int().positive();\nconst TIMEZONE = z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' });\n\n/**\n * The STRICT schema — the wire contract. The server rebuilds it from the link's\n * own fetched metadata and validates every booking against it; a slot and a\n * duration are not optional on anything that reaches HubSpot.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n  return buildBookingSchema(formFields, legalConsent, {\n    startTimeMs: SLOT_INSTANT,\n    durationMs: SLOT_INSTANT,\n    timezone: TIMEZONE,\n  });\n}\n\n/**\n * The DEFERRED schema — same fields, with the slot/duration/timezone triple\n * relaxed. `flow=\"details-first\"` collects answers BEFORE a slot exists, so the\n * form validates against this and the parent re-attaches the authoritative\n * values at POST time.\n *\n * Two named exports over one private builder rather than an overload or an\n * options flag: `MeetingBookingPayload` is `z.infer<ReturnType<...>>`, and\n * either of those alternatives would widen `startTimeMs`/`durationMs` to\n * `number | undefined` for EVERY consumer — including the server's\n * `durationsMs.includes(data.durationMs)`.\n */\nexport function makeDeferredBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n  // `.nullish()`, not `.optional()`: the widget passes these straight into\n  // `defaultValues`, and details-first has `timezone === null` on the server\n  // render and the first client render (zone resolution is post-hydration).\n  // Accepting only `undefined` would reject a field with no rendered control\n  // and no FieldWrapper — the submit button silently dead, type-check clean.\n  return buildBookingSchema(formFields, legalConsent, {\n    startTimeMs: SLOT_INSTANT.nullish(),\n    durationMs: SLOT_INSTANT.nullish(),\n    timezone: TIMEZONE.nullish(),\n  });\n}\n\n/** The wire payload. Pinned to the STRICT builder — see above. */\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n\n/**\n * The form's value type, used as the `useForm` generic in BOTH flows: a relaxed\n * resolver is not assignable to `Resolver<MeetingBookingPayload>`, and mixing\n * the two survives only on TS's bivariant method-parameter check.\n */\nexport type BookingFormValues = z.infer<ReturnType<typeof makeDeferredBookingSchema>>;\n","'use client';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { Fragment, useEffect, useMemo, useState } from 'react';\nimport type { FormEvent, ReactNode, Ref } from 'react';\nimport { useForm, Controller } from 'react-hook-form';\nimport type { Control, UseFormRegister } from 'react-hook-form';\nimport {\n  BUILT_IN_BOOKING_FIELDS,\n  type BuiltInBookingFieldName,\n  DECIMAL_LITERAL_RE,\n  fieldTypeSpec,\n  makeDeferredBookingSchema,\n  isSupportedFormField,\n  type BuiltInBookingField,\n  type MeetingAvailability,\n  type SupportedFormFieldType,\n  type MeetingFormField,\n  type SupportedMeetingFormField,\n  type BookingFormValues,\n} from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';\nimport {\n  Button,\n  FieldWrapper,\n  Input,\n  Textarea,\n  Label,\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n  Checkbox,\n  CheckboxBlock,\n  RadioGroup,\n  RadioGroupItem,\n  Skeleton,\n} from '../ui';\nimport { HoneypotField } from '../ui/honeypot-field';\n\n/**\n * One field in a host-supplied row. `name` is a built-in (`email`, `firstName`,\n * `lastName`) or a HubSpot-declared question's `name`.\n */\nexport interface BookingFieldSlot {\n  /** Built-ins autocomplete; a HubSpot question's `name` is whatever the link declares. */\n  name: BuiltInBookingFieldName | (string & NonNullable<unknown>);\n  /** Columns out of four at `md` and up. Defaults to an even split of the row. */\n  span?: BookingFieldSpan;\n}\n\n/** Columns out of `GRID_COLUMNS` at `md` and up — the keys of `SPAN_CLASS`. */\nexport type BookingFieldSpan = keyof typeof SPAN_CLASS;\n\nexport type BookingFieldRow = BookingFieldSlot[];\n\n/**\n * A HOST-supplied consent row — the block the waitlist form draws for its SMS\n * consent, here for \"I agree to the Privacy Policy and to be contacted\". It is\n * the host's copy and the host's link, so it is a prop, not HubSpot metadata;\n * HubSpot's own `legalConsent` block (verbatim, declared on the link) renders\n * alongside when the link carries one.\n *\n * A client-side gate, exactly like the waitlist's: Continue is refused until it\n * is ticked, and the tick rides in the payload as `hostConsent` — which the\n * host's book route strips as an undeclared key. It is not a HubSpot consent\n * record; declare `legalConsent` on the link when one is required.\n */\nexport interface BookingFormConsent {\n  label: ReactNode;\n  description?: ReactNode;\n  /** Shown under the row when Continue is pressed unticked. */\n  errorMessage?: string;\n}\n\n/** The form's vertical rhythm — one stack for the loaded form and its skeleton. */\nconst FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/** The row grid: two columns on a phone, four from `md`. The row gap is one step\n *  wider than the column gap because field messages hang ~16px below their\n *  control and would print over the next row's label at the column gap. */\nconst ROW_GRID = 'grid grid-cols-2 gap-x-[var(--spacing-system-m)] gap-y-[var(--spacing-system-lf)] md:grid-cols-4';\n\n/** The wire key of the host consent tick (stripped server-side, never sent to HubSpot). */\nconst HOST_CONSENT_KEY = 'hostConsent';\n\n/** The row grid's column count at `md` and up. `ROW_GRID`'s `md:grid-cols-4`\n *  and `SPAN_CLASS` are its Tailwind twins (literal so the scanner sees them). */\nconst GRID_COLUMNS = 4;\n\n/** Static so Tailwind's scanner sees every class — a template built from a\n *  runtime span would compile to nothing. */\nconst SPAN_CLASS = {\n  1: 'md:col-span-1',\n  2: 'md:col-span-2',\n  3: 'md:col-span-3',\n  4: 'md:col-span-4',\n} as const;\n\n/** One field's skeleton: label + control footprint — the CSS twin of `FieldWrapper`'s\n *  label row over `Input`'s `h-11 md:h-12` (the other skeleton heights below are\n *  the same kind of twin: the textarea, the consent row, the `Button`). */\nconst FIELD_SKELETON_CLASS = 'h-[4.75rem] w-full';\n\n/** The submit copy every standalone `BookingForm` gets; the slot-first preset reads the same constant. */\nexport const DEFAULT_SUBMIT_LABEL = 'Confirm Booking';\n\n/** The footer with a note (details-first): note beside the button, wrapping on a phone. */\nconst FOOTER_ROW_CLASS = 'flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]';\n/** The bare footer: the button stays LEFT, where the form's reading order ends. */\nconst FOOTER_BARE_CLASS = 'flex';\n/** The submit's width when a note sits beside it. */\nconst SUBMIT_WIDE_CLASS = 'md:w-60';\n\n/** The built-in layout when a host passes no rows — what the skeleton draws for it. */\nconst DEFAULT_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'email' }], [{ name: 'firstName' }, { name: 'lastName' }]];\n\n/** The even split of four columns over `count` slots; a remainder goes to the\n *  leading slots (three slots → 2/1/1), so a row never leaves a trailing gap. */\nexport const evenSpan = (count: number, index: number): BookingFieldSpan => {\n  const base = Math.floor(GRID_COLUMNS / Math.max(1, count));\n  const extra = GRID_COLUMNS - base * count;\n  return Math.min(GRID_COLUMNS, Math.max(1, index < extra ? base + 1 : base)) as BookingFieldSpan;\n};\n\n/** ONE column rule for a slot, read by the loaded form and its skeleton: a\n *  two-field row stays side by side on a phone — the rule the built-in name pair\n *  has always followed: two short fields cost one line instead of two on the\n *  layout that can least afford them — and the span applies from `md`. */\nconst slotColumnClass = (row: BookingFieldRow, slot: BookingFieldSlot, index: number): string =>\n  cn(row.length === 2 ? 'col-span-1' : 'col-span-2', SPAN_CLASS[slot.span ?? evenSpan(row.length, index)]);\n\n/** What one control needs: the field, its DOM id, where it registers in the\n *  form, and the form's own register/control. */\ninterface ControlArgs {\n  /** A declared question, or a built-in carrying its control hints (everything on the declaration that is not wire data). */\n  field: SupportedMeetingFormField & Partial<Omit<BuiltInBookingField, keyof MeetingFormField | 'requiredMessage'>>;\n  id: string;\n  /** Top-level for the built-ins (`email`), `formFields.<name>` for declared questions. */\n  registerName: string;\n  /** The field's current validation message, so the control can say `aria-invalid`. */\n  error?: string;\n  register: UseFormRegister<BookingFormValues>;\n  control: Control<BookingFormValues>;\n}\n\n/** A built-in carries its own placeholder; a declared question gets the one its\n *  TYPE derives (the registry's `placeholder`), so no copy lives here. */\nconst placeholderFor = (field: ControlArgs['field']): string | undefined =>\n  field.placeholder ?? fieldTypeSpec(field.type).placeholder?.(field);\n\n/** `<input type=\"number\">` accepts `1e3` and ` 12 `; the wire wants the\n *  decimal literal the validator checks. A value ALREADY in that shape passes\n *  verbatim (`007` included) — a long integer or a tiny decimal must not be\n *  reshaped through a float — and one that cannot be brought into it is left\n *  for the validator's own message. */\nconst canonicalNumber = (v: unknown): string => {\n  const s = String(v ?? '').trim();\n  if (s === '' || DECIMAL_LITERAL_RE.test(s)) return s;\n  const n = Number(s);\n  if (!Number.isFinite(n)) return s;\n  const canonical = String(n);\n  return DECIMAL_LITERAL_RE.test(canonical) ? canonical : s;\n};\n\n/**\n * ONE control per registry type. A `Record` over `SupportedFormFieldType` on\n * purpose: `FORM_FIELD_TYPES` (the schema module) is the single place a type is\n * declared, and this table cannot compile without an entry for each — so the\n * validator and the renderer can never disagree about what is supported.\n * Every control states `aria-invalid` from the field's message and carries\n * `required`/`aria-required` from the field, so the accent asterisk is never\n * the only signal.\n */\nconst FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => ReactNode> = {\n  text: ({ field, id, registerName, error, register }) => (\n    <Input\n      id={id}\n      type={field.inputType ?? 'text'}\n      required={field.required}\n      aria-invalid={Boolean(error)}\n      autoComplete={field.autoComplete}\n      placeholder={placeholderFor(field)}\n      {...register(registerName as never)}\n    />\n  ),\n  textarea: ({ field, id, registerName, error, register }) => (\n    <Textarea\n      id={id}\n      required={field.required}\n      aria-invalid={Boolean(error)}\n      placeholder={placeholderFor(field)}\n      {...register(registerName as never)}\n    />\n  ),\n  number: ({ field, id, registerName, error, register }) => (\n    <Input\n      id={id}\n      type=\"number\"\n      inputMode=\"decimal\"\n      step=\"any\"\n      required={field.required}\n      aria-invalid={Boolean(error)}\n      {...register(registerName as never, { setValueAs: canonicalNumber })}\n    />\n  ),\n  select: ({ field, id, registerName, error, control }) => (\n    <Controller\n      control={control}\n      name={registerName as never}\n      render={({ field: rhf }) => (\n        <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>\n          <SelectTrigger id={id} aria-required={field.required || undefined} aria-invalid={Boolean(error)}>\n            <SelectValue placeholder=\"Select…\" />\n          </SelectTrigger>\n          <SelectContent>\n            {(field.options ?? []).map(opt => (\n              <SelectItem key={opt} value={opt}>\n                {opt}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      )}\n    />\n  ),\n  radio: ({ field, id, registerName, error, control }) => (\n    <Controller\n      control={control}\n      name={registerName as never}\n      render={({ field: rhf }) => (\n        <RadioGroup\n          value={rhf.value ?? ''}\n          onValueChange={rhf.onChange}\n          aria-required={field.required || undefined}\n          aria-invalid={Boolean(error)}\n        >\n          {(field.options ?? []).map(opt => (\n            <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n              <RadioGroupItem id={`${id}-${opt}`} value={opt} />\n              <Label htmlFor={`${id}-${opt}`}>{opt}</Label>\n            </div>\n          ))}\n        </RadioGroup>\n      )}\n    />\n  ),\n  checkbox: ({ field, id, registerName, error, control }) => (\n    <Controller\n      control={control}\n      name={registerName as never}\n      render={({ field: rhf }) => (\n        // The question's Label above already carries the text and binds to\n        // this id — a second inline label would double the visible text AND\n        // the accessible name.\n        <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n          <Checkbox\n            id={id}\n            checked={Boolean(rhf.value)}\n            onCheckedChange={v => rhf.onChange(v === true)}\n            aria-required={field.required || undefined}\n            aria-invalid={Boolean(error)}\n          />\n        </div>\n      )}\n    />\n  ),\n};\n\n/** `firstName` → `ms-first-name`; the ids the built-ins have always had. */\nconst builtInId = (name: string) => `ms-${name.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}`;\n\nexport interface BookingFormProps {\n  availability: MeetingAvailability;\n  meetingId: string;\n  /** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */\n  startTimeMs?: number;\n  durationMs?: number;\n  /** IANA zone the confirmation/invite should render in (parent-resolved).\n   *  Null until hydration, which is why `deferSlot` relaxes it. */\n  timezone: string | null;\n  /**\n   * Collect-only mode (`flow=\"details-first\"`): validate against the deferred\n   * schema, and hand the values up instead of POSTing. The parent re-attaches\n   * the authoritative slot/duration/timezone when it submits.\n   */\n  deferSlot?: boolean;\n  /** Repopulates the form on a remount — the back edge, or an error return. */\n  initialValues?: Record<string, unknown>;\n  /** Defaults to \"Confirm Booking\"; details-first says \"Continue\". */\n  submitLabel?: string;\n  /** Small print beside the submit (details-first sets expectations). */\n  footerNote?: string;\n  /**\n   * Re-arrange the fields into rows instead of the built-in order (email, the\n   * name pair, then each declared question full width). Reuse, not a fork: the\n   * SAME controls, validation, consent block and honeypot — only their grouping\n   * changes, so a host can match a mock without owning the machine.\n   *\n   * A slot naming nothing is skipped; a declared question no row claims is\n   * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.\n   */\n  fieldRows?: BookingFieldRow[];\n  /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */\n  consent?: BookingFormConsent;\n  isSubmitting: boolean;\n  onSubmit: (payload: Record<string, unknown>) => Promise<void>;\n  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */\n  honeypotInputProps: { ref: Ref<HTMLInputElement>; name: string };\n  getSignals: () => Record<string, string | number>;\n}\n\n/**\n * BookingForm — attendee details + the link's declared custom questions +\n * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +\n * zodResolver + lib field primitives); a sibling rather than a `<ContactForm>`\n * configuration because of the dynamic HubSpot `formFields`, the per-checkbox\n * consent model, and the first/last-name field model — none expressible via\n * `hideFields`/`extraTopField`.\n *\n * Bot protection is LOAD-BEARING: without the humanity signals in the body,\n * the host's `verifyHuman` degrades to first-party-only BotID (fails open for\n * external embedders). Honeypot + elapsed-ms are merged into the POST at\n * submit; the parent calls `resetSignals()` after a SLOT_TAKEN refetch so a\n * legitimate retry isn't flagged too-fast.\n */\nexport function BookingForm({\n  availability,\n  meetingId,\n  startTimeMs,\n  durationMs,\n  timezone,\n  deferSlot = false,\n  initialValues,\n  submitLabel,\n  footerNote,\n  fieldRows,\n  consent,\n  isSubmitting,\n  onSubmit,\n  honeypotInputProps,\n  getSignals,\n}: BookingFormProps) {\n  const { formFields, legalConsent } = availability;\n  const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields]);\n  // The DEFERRED schema in both flows: it is the wider of the two, and a strict\n  // resolver is not assignable to `Resolver<BookingFormValues>`. The strict\n  // schema is the server's contract — see `makeBookingSchema`'s docblock.\n  const schema = useMemo(\n    () => makeDeferredBookingSchema(supportedFields, legalConsent),\n    [supportedFields, legalConsent],\n  );\n\n  const consentDefaults = useMemo(\n    () =>\n      (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({\n        communicationTypeId: c.communicationTypeId,\n        consented: false,\n      })),\n    [legalConsent],\n  );\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    setValue,\n    getValues,\n    formState: { errors },\n  } = useForm<BookingFormValues>({\n    resolver: zodResolver(schema),\n    defaultValues: {\n      meetingId,\n      // Genuinely OMITTED, not present-as-undefined, when the slot is deferred.\n      ...(deferSlot ? {} : { startTimeMs, durationMs }),\n      timezone,\n      locale: typeof navigator !== 'undefined' ? navigator.language : undefined,\n      firstName: '',\n      lastName: '',\n      email: '',\n      formFields: {},\n      legalConsentResponses: consentDefaults,\n      // A remount (back edge, or an error return) would otherwise lose every\n      // answer — `defaultValues` is snapshotted once and never re-read.\n      ...(initialValues ?? {}),\n    } as BookingFormValues,\n  });\n\n  const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;\n\n  // The host consent lives OUTSIDE react-hook-form (it is not a wire field), so\n  // a remount restores it from the stash the same way the fields come back.\n  const [consented, setConsented] = useState(initialValues?.[HOST_CONSENT_KEY] === true);\n  const [consentError, setConsentError] = useState<string | null>(null);\n  const consentMissing = Boolean(consent) && !consented;\n\n  // The seeded availability is refetched immediately on mount, so the link's\n  // consent set can change WHILE this form is open. Reconcile rather than\n  // remount: carry each `consented` across by id and default new ids to false.\n  // A wholesale reset would drop what the visitor has typed; leaving it alone\n  // would make a newly-declared required consent impossible to satisfy.\n  useEffect(() => {\n    // The LIVE answers first (what the visitor has ticked since mount), the\n    // stash only as the seed for a fresh remount — reading the stash alone\n    // would undo every tick made after mount when the consent set refreshes.\n    const current = (getValues('legalConsentResponses') ?? priorConsents ?? []) as typeof consentDefaults;\n    setValue(\n      'legalConsentResponses',\n      consentDefaults.map(next => ({\n        ...next,\n        consented: current.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,\n      })),\n      { shouldDirty: false },\n    );\n  }, [consentDefaults, priorConsents, getValues, setValue]);\n\n  const submitValid = handleSubmit(async data => {\n    if (consentMissing) return; // the error is already on screen — see `submit`\n    if (deferSlot) {\n      // Collect-only. The PARENT's `onSubmit` reads the humanity signals, and\n      // must do so synchronously in this call — this form and its honeypot are\n      // still mounted here, and `getSignals()` reads a detached ref once they\n      // unmount, which would silently disable the decoy.\n      await onSubmit({ ...data, meetingId, [HOST_CONSENT_KEY]: consented });\n      return;\n    }\n    await onSubmit({\n      ...data,\n      meetingId,\n      [HOST_CONSENT_KEY]: consented,\n      startTimeMs,\n      durationMs,\n      timezone,\n      ...getSignals(),\n    });\n  });\n\n  // Consent is checked BEFORE the resolver runs, not inside the valid branch,\n  // so an unticked box and an empty field are reported together rather than\n  // one submit apart.\n  const submit = (event: FormEvent<HTMLFormElement>) => {\n    if (consentMissing) setConsentError(consent?.errorMessage ?? 'Please agree to continue.');\n    return submitValid(event);\n  };\n\n  const fieldError = (name: string): string | undefined => {\n    const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];\n    return err?.message;\n  };\n\n  /** ONE render path for every field — built-in or declared — so the default\n   *  order below and any host-supplied `fieldRows` compose the same controls. */\n  const renderField = (\n    field: ControlArgs['field'],\n    where: { id: string; registerName: string; error?: string },\n  ): ReactNode => (\n    <FieldWrapper key={field.name} label={field.label} htmlFor={where.id} required={field.required} error={where.error}>\n      {FIELD_CONTROLS[field.type]({\n        field,\n        id: where.id,\n        registerName: where.registerName,\n        error: where.error,\n        register,\n        control,\n      })}\n    </FieldWrapper>\n  );\n\n  const builtInFields: Record<string, ReactNode> = Object.fromEntries(\n    BUILT_IN_BOOKING_FIELDS.map(field => [\n      field.name,\n      renderField(field, { id: builtInId(field.name), registerName: field.name, error: errors[field.name]?.message }),\n    ]),\n  );\n\n  const renderDeclaredField = (field: SupportedMeetingFormField): ReactNode =>\n    renderField(field, {\n      id: `ms-q-${field.name}`,\n      registerName: `formFields.${field.name}`,\n      error: fieldError(field.name),\n    });\n\n  const declaredByName = new Map(supportedFields.map(f => [f.name, f]));\n\n  const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));\n\n  const slotNode = (name: string): ReactNode => {\n    const builtIn = builtInFields[name];\n    if (builtIn) return builtIn;\n    const declared = declaredByName.get(name);\n    // A name matching nothing is SKIPPED, not an error: a row may reference a\n    // question the link has not declared yet (declaring it in HubSpot is what\n    // turns it on), and throwing would take the whole form down over config.\n    return declared ? renderDeclaredField(declared) : null;\n  };\n\n  /** Fields no row claims — appended full width, so neither a question added in\n   *  HubSpot nor a built-in the layout forgot can go invisible: the schema still\n   *  requires the identity trio, and a required field with no control is a\n   *  submit that dies silently. */\n  const { rows: placedRows, unplacedBuiltIns, named: placedNames } = normalizeFieldRows(fieldRows ?? [], slotResolves);\n  const unplacedFields = fieldRows ? supportedFields.filter(f => !placedNames.has(f.name)) : [];\n\n  const submitButton = (\n    <Button\n      type=\"submit\"\n      loading={isSubmitting}\n      disabled={isSubmitting}\n      // The details-first footer draws a 240px action beside its note\n      // (`4904:117335`); the bare slot-first row keeps the button at its natural\n      // width, as it always has.\n      className={footerNote ? SUBMIT_WIDE_CLASS : undefined}\n    >\n      {submitLabel ?? DEFAULT_SUBMIT_LABEL}\n    </Button>\n  );\n\n  // Opted out of HubSpot's collected-forms script — see HUBSPOT_DO_NOT_COLLECT_FORM_PROPS.\n  return (\n    <form onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>\n      <HoneypotField {...honeypotInputProps} />\n\n      {/* Email first, name pair below — the order the desktop and mobile mocks\n          both draw. The pair stays TWO columns even on a phone (164px each at\n          375): two short fields side by side cost one line instead of two on\n          the layout that can least afford them.\n\n          Every field goes through `FieldWrapper`, which hangs its message OUT\n          OF FLOW below the control. That is the whole reason it is here: a\n          message rendered in flow grows its field, which pushes everything\n          under it down and — inside a card that states its height — walks the\n          submit button off the bottom the moment validation fails. Required-ness\n          is carried by `required` on the control (read out by assistive tech)\n          AND by `FieldWrapper`'s accent asterisk — the mark HubSpot's own form\n          and `ContactForm` both draw, so a visitor sees the same convention on\n          every form in the app. */}\n      {fieldRows ? (\n        <>\n          {placedRows.map(row => (\n            <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n              {row.map((slot, slotIndex) => (\n                <div key={slot.name} className={slotColumnClass(row, slot, slotIndex)}>\n                  {slotNode(slot.name)}\n                </div>\n              ))}\n            </div>\n          ))}\n          {[...unplacedBuiltIns, ...unplacedFields].map(field => (\n            <Fragment key={field.name}>{slotNode(field.name)}</Fragment>\n          ))}\n        </>\n      ) : (\n        <>\n          {builtInFields.email}\n          <div className=\"grid grid-cols-2 gap-[var(--spacing-system-m)]\">\n            {builtInFields.firstName}\n            {builtInFields.lastName}\n          </div>\n          {supportedFields.map(field => (\n            <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>\n          ))}\n        </>\n      )}\n\n      {consent && (\n        <CheckboxBlock\n          id=\"ms-host-consent\"\n          checked={consented}\n          onCheckedChange={v => {\n            setConsented(v);\n            if (v) setConsentError(null);\n          }}\n          disabled={isSubmitting}\n          required\n          error={consentError ?? undefined}\n          label={consent.label}\n          description={consent.description}\n        />\n      )}\n\n      {legalConsent && (\n        // Same out-of-flow message as the fields above, so a missed consent\n        // box doesn't shove the submit button down the card. With no error the\n        // wrapper is `display:contents` and the panel below is the flex item,\n        // exactly as it was.\n        <FieldWrapper error={errors.legalConsentResponses?.message}>\n          <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n            {/* GDPR surface — HubSpot's copy rendered VERBATIM, never edited, in\n                HubSpot's own order: processing statement, communication intro,\n                the boxes, privacy note. The statement is a standalone line so a\n                link with consent enabled but NO communication boxes still shows\n                it. */}\n            <p className=\"text-ods-text-secondary text-h6\">{legalConsent.processingConsentText}</p>\n            {legalConsent.communicationConsentText && (\n              <p className=\"text-ods-text-secondary text-h6\">{legalConsent.communicationConsentText}</p>\n            )}\n            <Controller\n              control={control}\n              name=\"legalConsentResponses\"\n              render={({ field: rhf }) => (\n                <>\n                  {legalConsent.communicationConsentCheckboxes.map(box => {\n                    const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;\n                    const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);\n                    return (\n                      // The design system's consent row (`checkbox-block`, the\n                      // same block the waitlist form uses): box + label in one\n                      // bordered row.\n                      <CheckboxBlock\n                        key={box.communicationTypeId}\n                        id={`ms-consent-${box.communicationTypeId}`}\n                        checked={current?.consented ?? false}\n                        onCheckedChange={v =>\n                          rhf.onChange(\n                            responses.map(r =>\n                              r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,\n                            ),\n                          )\n                        }\n                        required={box.required}\n                        label={box.label}\n                      />\n                    );\n                  })}\n                </>\n              )}\n            />\n            {legalConsent.privacyPolicyText && (\n              <p className=\"text-ods-text-secondary text-h6\">{legalConsent.privacyPolicyText}</p>\n            )}\n          </div>\n        </FieldWrapper>\n      )}\n\n      {/* Step navigation back to the calendar lives in the step header (the\n          app-standard BackButton, rendered by the parent) — the form ships\n          only its submit. */}\n      {/* Two shapes, not one with a placeholder: without a note this must stay\n          the bare `flex` row it has always been, or the submit slides from the\n          left edge to the right on every existing slot-first booking. */}\n      {footerNote ? (\n        <div className={FOOTER_ROW_CLASS}>\n          <p className=\"text-ods-text-secondary text-h6\">{footerNote}</p>\n          {submitButton}\n        </div>\n      ) : (\n        <div className={FOOTER_BARE_CLASS}>{submitButton}</div>\n      )}\n    </form>\n  );\n}\n\n/**\n * Cold-start placeholder for `flow=\"details-first\"`, where the FORM is the\n * first thing in the action panel.\n *\n * The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar\n * where the form belongs — above the fold on a page whose entire content is\n * this card. Same footprint discipline as its sibling: fixed heights, and no\n * shift when the real form swaps in for single-line fields. Two footprints it\n * cannot know: a textarea row is taller than its placeholder (the rows carry no\n * type), and a HubSpot `legalConsent` block only exists once availability lands.\n */\nexport function BookingFormSkeleton({\n  fieldRows,\n  consent,\n  footerNote,\n}: {\n  fieldRows?: BookingFieldRow[];\n  /** Whether the host adds its consent row (drawn only then). A HubSpot\n   *  `legalConsent` block is unknown until availability lands — the one\n   *  footprint this skeleton cannot budget for. */\n  consent?: boolean;\n  footerNote?: string;\n}) {\n  return (\n    <div className={cn('flex-1', FORM_STACK)}>\n      {/* The host's rows (or the built-in layout) through the SAME grid, column\n          rule and normalisation as the loaded form — see `normalizeFieldRows`. */}\n      {skeletonRows(fieldRows ?? DEFAULT_FIELD_ROWS).map(row => (\n        <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>\n          {row.map((slot, slotIndex) => (\n            <Skeleton key={slot.name} className={cn(FIELD_SKELETON_CLASS, slotColumnClass(row, slot, slotIndex))} />\n          ))}\n        </div>\n      ))}\n      {/* Without rows there is no telling how many questions the link declares: one long answer stands for them. */}\n      {!fieldRows && <Skeleton className=\"h-[7.75rem] w-full\" />}\n      {consent && <Skeleton className=\"h-[4.25rem] w-full\" />}\n      {footerNote ? (\n        <div className={FOOTER_ROW_CLASS}>\n          <Skeleton className=\"h-5 min-w-40 flex-1\" />\n          <Skeleton className={cn('h-12 w-full', SUBMIT_WIDE_CLASS)} />\n        </div>\n      ) : (\n        <div className={FOOTER_BARE_CLASS}>\n          <Skeleton className=\"h-12 w-40\" />\n        </div>\n      )}\n    </div>\n  );\n}\n\n/**\n * The ONE normalisation of host rows, read by the form and its skeleton.\n *  - A slot whose name `resolves` to nothing is dropped, and a row left empty is\n *    dropped with it: an empty grid still eats one form gap, so a layout written\n *    ahead of the HubSpot config would print blank bands.\n *  - A name placed twice renders once (its first slot) — one control per field.\n *  - Explicit spans were written for the FULL row; once a slot drops out they\n *    would leave a trailing gap, so a shortened row falls back to the even split.\n *  - Built-ins no row names come back as `unplacedBuiltIns`: the schema still\n *    requires the identity trio, and a required field with no control is a\n *    submit that dies silently.\n * The skeleton has no availability yet, so it passes no `resolves` and keeps\n * every declared-looking name; that is the one swap it cannot rule out.\n */\nfunction normalizeFieldRows(fieldRows: BookingFieldRow[], resolves: (name: string) => boolean = () => true) {\n  const seen = new Set<string>();\n  const rows: BookingFieldRow[] = [];\n  for (const row of fieldRows) {\n    const kept: BookingFieldSlot[] = [];\n    for (const slot of row) {\n      if (!resolves(slot.name) || seen.has(slot.name)) continue;\n      seen.add(slot.name);\n      kept.push(slot);\n    }\n    if (kept.length === 0) continue;\n    rows.push(kept.length === row.length ? kept : kept.map((slot): BookingFieldSlot => ({ name: slot.name })));\n  }\n  const named = new Set(fieldRows.flat().map(slot => slot.name));\n  const unplacedBuiltIns = BUILT_IN_BOOKING_FIELDS.filter(f => !named.has(f.name));\n  return { rows, unplacedBuiltIns, named };\n}\n\n/** The skeleton's rows: the normalised host rows, then the built-ins none of them named. */\nfunction skeletonRows(fieldRows: BookingFieldRow[]): BookingFieldRow[] {\n  const { rows, unplacedBuiltIns } = normalizeFieldRows(fieldRows);\n  return [...rows, ...unplacedBuiltIns.map(f => [{ name: f.name }])];\n}\n","'use client';\n\nimport type { BookingConfirmation } from '../../schemas/meeting-booking-schema';\nimport { formatDurationCompact } from '../../utils/format';\n\n/**\n * Confirmation — terminal success state after a completed booking. Renders\n * the booked instant in the visitor's resolved zone. Deliberately NO\n * follow-up CTA: the job is done, the calendar invite is the next touchpoint\n * (a \"book another\" button here tested as noise).\n */\n\nexport interface ConfirmationProps {\n  confirmation: BookingConfirmation;\n  timezone: string;\n}\n\nexport function Confirmation({ confirmation, timezone }: ConfirmationProps) {\n  const when = new Intl.DateTimeFormat(undefined, {\n    timeZone: timezone,\n    weekday: 'long',\n    month: 'long',\n    day: 'numeric',\n    hour: 'numeric',\n    minute: '2-digit',\n    timeZoneName: 'short',\n  }).format(new Date(confirmation.startTimeMs));\n\n  return (\n    <div className=\"flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center\">\n      <p className=\"text-h1\" role=\"img\" aria-label=\"Celebration\">\n        🎉\n      </p>\n      <h3 className=\"text-ods-text-primary text-h3\">You&apos;re booked</h3>\n      <p className=\"text-ods-text-primary text-h5\">{confirmation.title}</p>\n      <p className=\"text-ods-text-secondary text-h6\">\n        {when} · {formatDurationCompact(confirmation.durationMs / 1000)}\n      </p>\n      <p className=\"text-ods-text-secondary text-h6\">A calendar invite is on its way to your email.</p>\n    </div>\n  );\n}\n","'use client';\n\nimport { useMemo } from 'react';\nimport type { MeetingHost } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { formatDurationCompact } from '../../utils/format';\nimport { BackButton } from '../layout/back-button';\nimport { SquareAvatar, AvatarStack, Button, Skeleton, Autocomplete } from '../ui';\n\n/**\n * ContextPanel — the \"who / what / how long\" side of the scheduler card\n * (Calendly-anatomy left panel; stacks on top on mobile). Everything here is\n * trust surface: host identity (avatar + name + title), the meeting's own\n * title/description, the duration (chips when the link offers several — the\n * duration choice lives HERE, not as a separate wizard step), and a\n * SEARCHABLE timezone picker (all IANA zones with live GMT offsets;\n * rendering-only — the wire is always epoch-ms).\n *\n * `onBack` (optional) puts the card's ONE back edge at the TOP of this panel —\n * the one spot that stays put while the action side swaps between calendar,\n * form and confirmation. What it means is the caller's call: the scheduler\n * points it at the previous STEP from the form, and at the host's own exit\n * from the calendar. One affordance, one place, so the visitor never has to\n * work out which of two \"Back\"s goes where.\n *\n * The picker renders only once the parent resolves the zone post-mount — SSR\n * output stays deterministic; a same-footprint skeleton holds the space.\n */\n\nexport interface SchedulerContextPanelProps {\n  hosts: MeetingHost[];\n  title?: string;\n  description?: string | null;\n  durationsMs: number[];\n  selectedDurationMs: number | null;\n  onSelectDuration: (ms: number) => void;\n  /** Resolved IANA zone (null until the client resolves it). */\n  timezone: string | null;\n  onTimezoneChange?: (tz: string) => void;\n  /** Host-level exit (renders a `BackButton` at the top). Omitted → no back\n   *  affordance, which is what an embedder with its own page chrome wants. */\n  onBack?: () => void;\n  /** Label for {@link onBack}. */\n  backLabel?: string;\n  /** True once a slot is chosen (details/confirmed steps) — the duration\n   *  CHIPS give way to a static duration line, since the step summary already\n   *  states the length and a live selector beside a filled-in form invites a\n   *  change that would throw the form away. Going Back restores them. */\n  locked?: boolean;\n  /** A POST is in flight: the duration chips and the zone control stay MOUNTED\n   *  (nothing may shift under the spinning chip) but accept no input. */\n  disabled?: boolean;\n  /** Whether the timezone picker renders. Default true: the zone governs every\n   *  time on screen, the form's summary line included, so it stays reachable\n   *  right up to the confirmation — where there is nothing left to re-read. */\n  showTimezone?: boolean;\n  className?: string;\n}\n\n/** Panel stack — one rhythm shared by the loaded panel and its skeleton\n *  (16px on phones, 24px from tablet up, via the responsive spacing token). */\nconst PANEL_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';\n\n/**\n * Identity + duration: ONE line below `lg`, stacked from `lg` up.\n *\n * The panel is a 280px sidebar from `lg` and a full-width header strip below\n * it — and a header strip has horizontal room the sidebar never had, so the\n * duration moves up beside the host instead of taking a line of its own. Same\n * two children, different axis; nothing is hidden at any width.\n */\nconst IDENTITY_ROW =\n  'flex items-center justify-between gap-[var(--spacing-system-m)] lg:flex-col lg:items-stretch lg:gap-[var(--spacing-system-l)]';\n\n/**\n * Everything under the back edge, in TWO groups: identity/title/duration, and\n * the timezone field beside them.\n *\n * Side by side only on TABLET. There the panel is a full-width strip over the\n * calendar, and it is competing with the calendar for the card's height — a\n * timezone field on a line of its own costs 100px there, which is a third of\n * what the month needs to render without scrolling. Sideways it costs nothing:\n * the strip is ~725px wide and the identity block uses less than half of it.\n *\n * Below `md` (phone) and from `lg` up (280px sidebar) there is no horizontal\n * room to spend, so both fall back to the plain stack.\n */\nconst STRIP_BODY = 'flex flex-col gap-[var(--spacing-system-l)] md:flex-row md:items-start lg:flex-col';\nconst STRIP_MAIN = 'flex min-w-0 flex-col gap-[var(--spacing-system-l)] md:flex-1';\n/** 280px — the same width this panel has as a sidebar, so the field is the\n *  size the visitor meets at every other breakpoint. */\nconst STRIP_ASIDE = 'flex flex-col gap-[var(--spacing-system-xxs)] md:w-[280px] md:shrink-0 lg:w-full';\n\n/** The back affordance sits flush with the panel's top padding: `BackButton`'s\n *  own 12px block padding would otherwise double the 24px stack gap under it. */\nconst BACK_BUTTON_CLASS = 'py-0';\n\n/** Field labels (\"Duration\", \"Timezone\") — body-weight primary, 4px above\n *  their control, so the panel reads as a form rather than as a stack of\n *  section headers. */\nconst FIELD_LABEL_CLASS = 'text-h4 text-ods-text-primary';\n\nfunction zoneLabel(tz: string): string {\n  try {\n    const parts = new Intl.DateTimeFormat('en-US', {\n      timeZone: tz,\n      timeZoneName: 'shortOffset',\n    }).formatToParts(new Date());\n    const offset = parts.find(p => p.type === 'timeZoneName')?.value ?? '';\n    return offset ? `${tz.replace(/_/g, ' ')} (${offset})` : tz.replace(/_/g, ' ');\n  } catch {\n    return tz.replace(/_/g, ' ');\n  }\n}\n\n/**\n * Same-footprint skeleton — swaps with the loaded panel with zero shift.\n * STATIC labels (\"Duration\", \"Timezone\") render REAL; only data-driven\n * content (host identity, duration chips, the zone value) is skeleton.\n */\nexport function ContextPanelSkeleton({\n  className,\n  onBack,\n  backLabel = 'Back',\n}: {\n  className?: string;\n  onBack?: () => void;\n  backLabel?: string;\n}) {\n  return (\n    <div className={cn(PANEL_STACK, className)}>\n      {/* The back edge renders REAL here — it needs no data, and holding the\n          space with a bar would just move on load. */}\n      {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n      {/* Same two groups, same rows, as the loaded panel — so the header strip\n          keeps its height and nothing re-flows when it swaps in. */}\n      <div className={STRIP_BODY}>\n        <div className={STRIP_MAIN}>\n          <div className={IDENTITY_ROW}>\n            <div className=\"flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xs)] lg:flex-none\">\n              <Skeleton className=\"h-8 w-8 shrink-0 rounded-full\" />\n              <div className=\"flex flex-1 flex-col gap-[var(--spacing-system-xxs)]\">\n                <Skeleton className=\"h-5 w-32\" />\n                <Skeleton className=\"h-4 w-24\" />\n              </div>\n            </div>\n            <Skeleton className=\"h-4 w-20 shrink-0\" />\n          </div>\n        </div>\n        <div className={STRIP_ASIDE}>\n          <p className={FIELD_LABEL_CLASS}>Timezone</p>\n          <Skeleton className=\"h-12 w-full\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function SchedulerContextPanel({\n  hosts,\n  title,\n  description,\n  durationsMs,\n  selectedDurationMs,\n  onSelectDuration,\n  timezone,\n  onTimezoneChange,\n  onBack,\n  backLabel = 'Back',\n  locked = false,\n  disabled = false,\n  showTimezone = true,\n  className,\n}: SchedulerContextPanelProps) {\n  // All IANA zones with live GMT offsets — computed once, client-only (the\n  // panel renders the picker only after the parent resolves a zone).\n  const zoneOptions = useMemo(() => {\n    if (!timezone) return [];\n    let zones: string[];\n    try {\n      // Older lib targets don't type supportedValuesOf (ES2022) — runtime-guarded.\n      const intl = Intl as typeof Intl & { supportedValuesOf?: (key: string) => string[] };\n      zones = intl.supportedValuesOf ? intl.supportedValuesOf('timeZone') : [timezone];\n    } catch {\n      zones = [timezone];\n    }\n    if (!zones.includes(timezone)) zones = [timezone, ...zones];\n    return zones.map(tz => ({ value: tz, label: zoneLabel(tz) }));\n  }, [timezone]);\n\n  return (\n    <div className={cn(PANEL_STACK, className)}>\n      {/* The card's ONE back edge, at every step — the caller decides where it\n          goes (previous step, or out of the scheduler). It sits in this panel\n          because this is the half that never swaps. */}\n      {onBack && <BackButton label={backLabel} onClick={onBack} className={BACK_BUTTON_CLASS} />}\n\n      <div className={STRIP_BODY}>\n        <div className={STRIP_MAIN}>\n          <div className={IDENTITY_ROW}>\n            {/* Host identity: full rows up to 3 hosts; larger teams (round-robin\n                links can carry many members) collapse to a stacked-avatar cluster\n                + count so the panel can never overflow. */}\n            {hosts.length > 0 && hosts.length <= 3 && (\n              <div className=\"flex flex-col gap-[var(--spacing-system-s)]\">\n                {hosts.map(host => (\n                  <div key={host.name} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n                    <SquareAvatar\n                      variant=\"round\"\n                      size=\"sm\"\n                      src={host.avatarUrl ?? undefined}\n                      alt={host.name}\n                      fallback={host.name}\n                    />\n                    <div className=\"flex min-w-0 flex-col\">\n                      <p className=\"truncate text-ods-text-primary text-h4\">{host.name}</p>\n                      {host.title && <p className=\"truncate text-ods-text-secondary text-h6\">{host.title}</p>}\n                    </div>\n                  </div>\n                ))}\n              </div>\n            )}\n            {hosts.length > 3 && (\n              <div className=\"flex flex-col gap-[var(--spacing-system-xs)]\">\n                <AvatarStack people={hosts} max={4} size=\"lg\" />\n                <p className=\"text-ods-text-secondary text-h6\">{hosts.length} hosts on this calendar</p>\n              </div>\n            )}\n\n            {locked || durationsMs.length <= 1\n              ? // Post-selection, or a link that offers one length: a static line,\n                // which is what sits beside the host on the header-strip layouts.\n                selectedDurationMs != null && (\n                  <p className=\"shrink-0 text-ods-text-secondary text-h6\">\n                    {formatDurationCompact(selectedDurationMs / 1000)} call\n                  </p>\n                )\n              : null}\n          </div>\n\n          {title && <p className=\"text-ods-text-primary text-h3\">{title}</p>}\n          {description && <p className=\"text-ods-text-secondary text-h6\">{description}</p>}\n\n          {/* Several lengths on offer: chips, which need a full row of their\n              own at every width — so they stay OUT of the identity row. */}\n          {!locked && durationsMs.length > 1 && (\n            <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n              <p className={FIELD_LABEL_CLASS}>Duration</p>\n              <div className=\"flex flex-wrap gap-[var(--spacing-system-xs)]\">\n                {durationsMs.map(ms => (\n                  <Button\n                    key={ms}\n                    variant={selectedDurationMs === ms ? undefined : 'outline'}\n                    size=\"small-legacy\"\n                    disabled={disabled}\n                    onClick={() => onSelectDuration(ms)}\n                  >\n                    {formatDurationCompact(ms / 1000)}\n                  </Button>\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n\n        {showTimezone && (\n          // Once a slot is chosen the picker is desktop-only, which is what all\n          // three mocks draw. On the narrow layouts this panel is a strip\n          // COMPETING with the form for the screen's height, and the step's own\n          // summary line states the zone one row below — so the fact survives,\n          // only the control steps aside. The 280px sidebar has the room, so\n          // there it stays.\n          <div className={cn(STRIP_ASIDE, locked && 'max-lg:hidden')}>\n            <p className={FIELD_LABEL_CLASS}>Timezone</p>\n            {timezone ? (\n              <Autocomplete\n                value={timezone}\n                disabled={disabled}\n                onChange={tz => {\n                  if (tz) onTimezoneChange?.(tz);\n                }}\n                options={zoneOptions}\n                placeholder=\"Search timezone…\"\n                noOptionsText=\"No matching timezone\"\n                showClearAll={false}\n              />\n            ) : (\n              <Skeleton className=\"h-12 w-full\" />\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { ChevronRight } from 'lucide-react';\nimport { useCallback, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention';\nimport { Button, DatePickerCalendar, Skeleton } from '../ui';\n\n/**\n * SlotPicker — day layer + time-chip grid for the meeting scheduler.\n *\n * Day layer is the design system's own `DatePickerCalendar`, in its `bare`\n * variant — the SAME component behind `DatePicker`'s popover and\n * `DateFilterMenu`, not a second day grid styled to look like it; `bare` only\n * drops the card surface and inset it would otherwise draw inside this panel. `fromDate`/`toDate` pin\n * navigation to [current month, current + MAX_MONTH_OFFSET] so the past is\n * unreachable, and `disabledDays` greys out days with no bookable slot —\n * bounds and availability are different questions, so they arrive separately.\n * Month navigation drives the host-proxy `monthOffset`, which is why the month\n * is CONTROLLED here rather than owned by the calendar. Time chips are plain\n * `Button`s whose variant flips on selection — no bespoke pill.\n *\n * LOADING (no blink, ever): the two columns know different amounts, so they\n * load differently. A CALENDAR is fully derivable from the date — which month,\n * which weekday each cell falls on — and only WHICH DAYS ARE BOOKABLE needs\n * the network. So it never unmounts: while a month loads it renders for real\n * with nothing yet selectable, which is exactly true, and lights up in place.\n * Only the times column, which genuinely has no answer until the response\n * lands, shows a placeholder.\n *\n * Swapping the calendar for a skeleton was the old behaviour and it is what\n * made paging months flash: the whole grid vanished and rebuilt to say\n * something it already knew.\n *\n * Each column carries its OWN heading — \"Select Date & Time\" over the\n * calendar, the chosen day over the chips. The headings live HERE rather than\n * above the whole picker so each sits with the thing it labels, and so the\n * skeletons reproduce them without the parent rendering a heading it cannot\n * keep in step.\n *\n * All slot instants are epoch-ms; every label is rendered in `timezone`\n * (resolved by the parent AFTER mount — this component never reads Intl\n * itself, keeping SSR output deterministic).\n */\n\nexport interface SlotPickerProps {\n  /** Bookable slot start times (epoch ms) for the selected duration. */\n  slots: number[];\n  /** IANA zone every label renders in (parent resolves post-mount). */\n  timezone: string;\n  monthOffset: number;\n  onMonthOffsetChange: (offset: number) => void;\n  selectedSlot: number | null;\n  onSelectSlot: (startMs: number) => void;\n  selectedDay: string | null;\n  onSelectDay: (dayKey: string) => void;\n  /** Availability refetch in flight (month change) → per-region skeletons. */\n  isLoading?: boolean;\n  /**\n   * A booking POST is in flight (details-first, where the slot click IS the\n   * submit). DISTINCT from `isLoading`, which swaps the whole times column for\n   * a skeleton — here the grid must stay up with the clicked chip spinning, so\n   * the visitor can see WHICH time is being booked.\n   */\n  isSubmitting?: boolean;\n}\n\n/** Stable per-zone day key for an instant, e.g. \"2026-08-14\" (exported — the\n *  parent uses it to auto-select the first available day). */\nexport function dayKeyInZone(ms: number, timeZone: string): string {\n  return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(\n    new Date(ms),\n  );\n}\n\n/**\n * Calendar-side day-key anchoring: react-day-picker exchanges PLAIN calendar\n * dates (local Date objects whose y/m/d ARE the cell). Convert via local\n * date parts — never through an Intl zone — or a `displayTimezone` far from\n * the browser zone shifts cells by a day.\n */\nconst pad2 = (n: number) => String(n).padStart(2, '0');\nfunction calendarDayKey(day: Date): string {\n  return `${day.getFullYear()}-${pad2(day.getMonth() + 1)}-${pad2(day.getDate())}`;\n}\nfunction dateFromDayKey(key: string): Date {\n  const [y, m, d] = key.split('-').map(Number);\n  return new Date(y, m - 1, d, 12);\n}\n\nfunction timeLabelInZone(ms: number, timeZone: string): string {\n  return new Intl.DateTimeFormat(undefined, { timeZone, hour: 'numeric', minute: '2-digit' }).format(new Date(ms));\n}\n\n/**\n * The calendar column's width, once the layout stops handing it one: on a\n * phone the section spans the card, from `md` up it takes a stated width and\n * the times column gets the rest.\n *\n * Width no longer decides height — `DatePickerCalendar bare` is a fixed 272px\n * block at any width — so these two numbers answer one question only: how much\n * room the month CAPTION needs. The two DS icon buttons and their gaps take\n * 104px of the header, and \"September 2026\" is ~131 at text-h4, so the grid\n * needs ~245px of inner width before the label starts colliding with the\n * next-month button. 304 on tablet (256 inner, this column pads itself) and\n * 256 on the desktop card, where the panel supplies the padding.\n *\n * That is the trade the standard button size buys: the chrome keeps its\n * 48px targets and the COLUMN widens to house it, rather than the buttons\n * shrinking to fit a narrower column.\n */\nconst CALENDAR_W = 'md:w-[19rem] md:shrink-0 lg:w-[16rem]';\n\n/** Column heading (\"Select Date & Time\" / the chosen day) — body-weight\n *  primary, 4px above its content, mirrored by both skeletons. */\nconst COLUMN_HEADING_CLASS = 'shrink-0 text-h4 text-ods-text-primary';\nconst COLUMN_STACK = 'flex min-h-0 flex-col gap-[var(--spacing-system-xxs)]';\n\n/** Static label over the calendar. Exported so a host rendering its own\n *  chrome around the picker can stay in step with it. */\nexport const SLOT_PICKER_HEADING = 'Select Date & Time';\n\n/**\n * Chip grid: `auto-fill` over `minmax(5.5rem, 1fr)` — as many chips per row as\n * fit at the minimum, then the leftover width shared out between them.\n *\n * Both halves matter. The 5.5rem MINIMUM is the widest 12-hour label\n * (\"12:45 PM\") plus the chip's padding: the mocks draw 65px chips five across\n * a phone, but they are lettered in a 24-hour locale (\"13:45\") and this\n * renders in the visitor's own — a chip that clips its own time is worse than\n * one column fewer. The `1fr` MAXIMUM is what makes the grid reach the card's\n * edge like the design instead of stopping short with a ragged strip of empty\n * space beside the last column, which is what a fixed track width leaves\n * behind at every width that is not an exact multiple of it.\n *\n * `auto-fill` is what keeps `1fr` safe here: the track COUNT still comes from\n * the minimum, so a wide column answers with more chips rather than with\n * fifteen-minute slots stretched into 200px slabs.\n *\n * Every track is the same width, so rows line up and nothing staggers.\n */\nconst CHIP_GRID_CLASS = cn(\n  'grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] content-start gap-[var(--spacing-system-xs)]',\n  'overflow-y-auto',\n  // PHONE: a stated window (~3 rows, the mock's 156px) that scrolls, rather\n  // than a list that runs the length of the day. Nothing else on the phone\n  // card has a height — the design lets each step be as tall as it needs —\n  // but a busy day is forty slots, and letting them all through turns a\n  // 700px card into a 1500px one and buries whatever the page puts below\n  // it. This is the one nested scroller worth having: a small, obviously\n  // bounded list with a visible edge, exactly as drawn.\n  'max-h-[9.75rem] md:max-h-none',\n  // No height of its own, and none borrowed from the calendar either: the card\n  // states one height, the panel and this row pass it down, and what is left\n  // after the column heading IS the scroll box. A long day scrolls inside it.\n  //\n  // `min-h-0` is the load-bearing half — a flex item's default\n  // `min-height:auto` refuses to shrink below its content, so without it the\n  // chips grow the column, then the row, then the card, and `overflow-y-auto`\n  // never gets a box smaller than its contents to scroll inside. That is the\n  // exact failure that kept this card at 404px when it was told to be 318.\n  'min-h-0 flex-1',\n);\n/** The track sets the width; the chip fills it, so labels can't stagger it.\n *  The tighter inline padding is what lets the track hold a 12-hour label —\n *  the Button's own is sized for prose, not for a time. */\nconst CHIP_CLASS = 'w-full px-[var(--spacing-system-xxs)]';\n\n/**\n * The two columns, at three widths.\n *\n *   < md  phone   — stacked sections, a full-bleed rule between them\n *   md    tablet  — side by side, the calendar at its stated width and the\n *                   times taking the rest, a vertical rule between them\n *   lg    desktop — side by side inside the panel's own padding, separated by\n *                   a gap (the card already has a rule there: the context\n *                   panel's edge)\n *\n * From `md` up the row takes what the card has left (`flex-1 min-h-0`) and\n * hands each column that same definite height. The calendar is a fixed 272px\n * block and does not scroll; the times column holds whatever the day offers\n * and scrolls ON ITS OWN. Two boxes, one height, one scrollbar — the calendar\n * stays put while the visitor runs down the times.\n *\n * Below `md` the card pins no height, so the row simply grows and the PAGE\n * scrolls — a nested scroller inside a hand-width card traps the gesture and\n * is worse than the thing it saves.\n */\nconst SLOT_ROW_CLASS = cn(\n  'flex min-w-0 flex-col md:flex-row',\n  // Default `stretch` at every width, deliberately. Stacked, that is what\n  // makes each section span the card — an `items-start` here (it was here, to\n  // stop a column growing taller than its month) collapses both sections to\n  // their intrinsic WIDTH instead, which on a phone left the calendar and the\n  // chips huddled against the left edge with half the card empty. The\n  // calendar no longer needs protecting: its height is fixed, not derived.\n  'md:min-h-0 md:flex-1 lg:gap-[var(--spacing-system-lf)]',\n);\n\n/** Shared by both columns: self-padded until the panel takes over at `lg`.\n *  Widths are per-column — the calendar states one (`CALENDAR_W`) and the\n *  times take what is left, rather than a 50/50 split that would size the\n *  month by the card's width. */\nconst COLUMN_BOX = 'p-[var(--spacing-system-l)] lg:p-0';\n\n/** The rule between the two, wherever the layout puts it. */\nconst COLUMN_DIVIDER = 'border-b border-ods-border md:border-b-0 md:border-r lg:border-0';\n\n/** Deterministic month caption for a given offset (fixed en-US locale so\n *  the SSR and client first-paint markup agree byte-for-byte). */\nexport function monthLabelFor(offset: number): string {\n  const now = new Date();\n  const month = new Date(now.getFullYear(), now.getMonth() + offset, 1);\n  return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month);\n}\n\n/** Nothing is bookable yet — used while a month is still loading. */\nconst DISABLE_EVERY_DAY = () => true;\nconst NOOP = () => undefined;\n\ninterface DayCalendarProps {\n  monthOffset: number;\n  onMonthOffsetChange: (offset: number) => void;\n  selectedDay: string | null;\n  onSelectDay: (dayKey: string) => void;\n  /** Days that ARE bookable; `null` while the month is still loading. */\n  slotsByDay: Map<string, number[]> | null;\n}\n\n/**\n * The day layer — one component for every state, which is the whole point:\n * loading a month changes WHICH DAYS ARE ENABLED, nothing else, so the grid\n * stays mounted and the visitor never watches it disappear and come back.\n *\n * `aria-busy` carries what the missing days mean to assistive tech, since\n * visually it is just an inert month.\n */\nfunction DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDay, slotsByDay }: DayCalendarProps) {\n  const now = new Date();\n  const startMonth = new Date(now.getFullYear(), now.getMonth(), 1);\n  // Day-0 of the FOLLOWING month = the last day of the last bookable one.\n  // `DatePickerCalendar` takes day bounds, not month bounds, so an end date\n  // pointing at the 1st would disable the rest of that month.\n  const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET + 1, 0);\n  const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n\n  // Stable identity: the calendar memoizes its matcher list on this value, so\n  // a fresh closure per render would rebuild it every render.\n  const dayHasNoSlots = useCallback((day: Date) => !slotsByDay?.has(calendarDayKey(day)), [slotsByDay]);\n\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col\" aria-busy={slotsByDay === null}>\n      <DatePickerCalendar\n        mode=\"single\"\n        bare\n        // CONTROLLED month: paging it is a DATA event here (the parent\n        // refetches availability for the new offset), not just a view change\n        // the calendar could own.\n        month={visibleMonth}\n        onMonthChange={month => {\n          const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth());\n          onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)));\n        }}\n        fromDate={startMonth}\n        toDate={endMonth}\n        selected={selectedDay ? dateFromDayKey(selectedDay) : undefined}\n        onSelect={value => {\n          const day = value as Date | undefined;\n          if (!day) return;\n          const key = calendarDayKey(day);\n          if (slotsByDay?.has(key)) onSelectDay(key);\n        }}\n        disabledDays={slotsByDay === null ? DISABLE_EVERY_DAY : dayHasNoSlots}\n      />\n    </div>\n  );\n}\n\n/**\n * The slot area before the first response — the pre-mount window while the\n * display timezone resolves, and the first availability request.\n *\n * The calendar half is REAL, not a placeholder: see the module docblock. Only\n * the times column is a skeleton, and it is the same one the loaded picker\n * shows while a month loads, so there is no swap between the two.\n */\nexport function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {\n  return (\n    <div className={SLOT_ROW_CLASS}>\n      <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n        {/* Needs no data — the heading renders REAL in every loading state. */}\n        <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n        <DayCalendar\n          monthOffset={monthOffset}\n          onMonthOffsetChange={NOOP}\n          selectedDay={null}\n          onSelectDay={NOOP}\n          slotsByDay={null}\n        />\n      </div>\n      <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n        <TimeChipsSkeleton />\n      </div>\n    </div>\n  );\n}\n\n/** Same-footprint skeleton for the time-chip column. */\nexport function TimeChipsSkeleton() {\n  return (\n    <>\n      {/* The day label is data — but it is held inside the REAL heading line\n          box, so the skeleton's first row sits exactly where the label lands. */}\n      <div className={COLUMN_HEADING_CLASS}>\n        <Skeleton className=\"inline-block h-4 w-40 align-middle\" />\n      </div>\n      <div className={CHIP_GRID_CLASS}>\n        {/* Enough to fill the visible scroll box at any column count — the\n            grid's track count follows the card width, so no fixed number\n            lands flush and a partial last row is the honest shape anyway. */}\n        {Array.from({ length: 16 }, (_, i) => (\n          // h-11 md:h-12 — the loaded chip's own default-size height.\n          <Skeleton key={i} className={cn(CHIP_CLASS, 'h-11 md:h-12')} />\n        ))}\n      </div>\n    </>\n  );\n}\n\nexport function SlotPicker({\n  slots,\n  timezone,\n  monthOffset,\n  onMonthOffsetChange,\n  selectedSlot,\n  onSelectSlot,\n  selectedDay,\n  onSelectDay,\n  isLoading = false,\n  isSubmitting = false,\n}: SlotPickerProps) {\n  const slotsByDay = useMemo(() => {\n    const map = new Map<string, number[]>();\n    for (const ms of slots) {\n      const key = dayKeyInZone(ms, timezone);\n      const list = map.get(key) ?? [];\n      list.push(ms);\n      map.set(key, list);\n    }\n    for (const list of map.values()) list.sort((a, b) => a - b);\n    return map;\n  }, [slots, timezone]);\n\n  const visibleMonth = useMemo(() => {\n    const now = new Date();\n    return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);\n  }, [monthOffset]);\n\n  const daySlots = selectedDay ? (slotsByDay.get(selectedDay) ?? []) : [];\n\n  // Whether the VISIBLE month has anything at all. HubSpot's monthOffset\n  // payloads can carry near-term slots from outside the requested month, so\n  // this asks about the grid on screen, not about the payload's size.\n  const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`;\n  const monthHasSlots = [...slotsByDay.keys()].some(key => key.startsWith(monthPrefix));\n\n  return (\n    <div className={SLOT_ROW_CLASS}>\n      <div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>\n        <p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>\n        {/* Stays mounted through a month load — `slotsByDay: null` is what\n            \"nothing selectable yet\" looks like, not an unmount. */}\n        <DayCalendar\n          monthOffset={monthOffset}\n          onMonthOffsetChange={onMonthOffsetChange}\n          selectedDay={selectedDay}\n          onSelectDay={onSelectDay}\n          slotsByDay={isLoading ? null : slotsByDay}\n        />\n      </div>\n      <div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>\n        {isLoading ? (\n          <TimeChipsSkeleton />\n        ) : selectedDay ? (\n          <>\n            <p className={COLUMN_HEADING_CLASS}>\n              {/* Format the DAY-KEY itself (plain calendar date) — re-zoning a\n                  local instant could label an adjacent day. */}\n              {new Intl.DateTimeFormat(undefined, {\n                weekday: 'long',\n                month: 'long',\n                day: 'numeric',\n              }).format(dateFromDayKey(selectedDay))}\n            </p>\n            <div className={CHIP_GRID_CLASS}>\n              {daySlots.map(ms => {\n                const isSelected = selectedSlot === ms;\n                return (\n                  <Button\n                    key={ms}\n                    variant={isSelected ? undefined : 'outline'}\n                    // The click is the submit in details-first, so the grid\n                    // itself is the double-book guard — the form button that\n                    // used to carry `disabled={isSubmitting}` is not on screen.\n                    disabled={isSubmitting}\n                    loading={isSubmitting && isSelected}\n                    onClick={() => {\n                      if (isSubmitting) return;\n                      onSelectSlot(ms);\n                    }}\n                    className={CHIP_CLASS}\n                  >\n                    {timeLabelInZone(ms, timezone)}\n                  </Button>\n                );\n              })}\n            </div>\n          </>\n        ) : monthHasSlots ? (\n          <p className=\"text-ods-text-secondary text-h6\">Pick a day to see available times.</p>\n        ) : (\n          // A fully-booked month used to just SAY \"try the next one\" and leave\n          // the visitor to find the chevron in the other column. The way out\n          // of a dead end belongs in the dead end.\n          <div className=\"flex flex-col items-start gap-[var(--spacing-system-s)]\">\n            <p className=\"text-ods-text-secondary text-h6\">No available times in {monthLabelFor(monthOffset)}.</p>\n            {monthOffset < MAX_MONTH_OFFSET && (\n              <Button\n                variant=\"outline\"\n                onClick={() => onMonthOffsetChange(monthOffset + 1)}\n                rightIcon={<ChevronRight className=\"size-5\" />}\n              >\n                Check {monthLabelFor(monthOffset + 1)}\n              </Button>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\n/**\n * `<MeetingSchedulerDirectory />` — the embeddable scheduling DIRECTORY\n * block: clickable row cards (host avatars, title/description, For-<audience>\n * chip, next available time, boxed chevron) + the house\n * `PersistentPaginationWrapper`, fed by a host proxy's `GET\n * {apiBaseUrl}/api/meetings` (see `docs/EMBEDDING_HUBSPOT_MEETINGS.md`).\n * Rows navigate to `{bookingBasePath}/{slug}` — pair with\n * `<HubSpotMeetingScheduler>` on that page for the full white-label flow.\n *\n * Layout stability is engineered, not incidental: the rows area reserves one\n * FULL page (pageSize × 80px rows + gaps), the pagination slot holds its\n * measured 72px even when hidden, and the loading state renders same-shell\n * skeleton rows — so nothing below the block ever jumps across\n * loading ⇄ loaded ⇄ page changes ⇄ short last pages.\n *\n * SSR/host mode: pass `initialData` (host-fetched) to skip the client fetch.\n * Client/embed mode: omit it — skeleton page + self-fetch via `contentFetch`\n * (embed-auth adapters inherited for free).\n */\n\nimport { ChevronRight } from 'lucide-react';\nimport { useEffect, useMemo, useState } from 'react';\nimport Link from '../../embed-shims/next-link';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';\nimport { cn } from '../../utils/cn';\nimport { contentFetch } from '../../utils/embed-content-fetch';\nimport { formatDurationCompact } from '../../utils/format';\nimport { pageCount } from '../../utils/search-params';\nimport { EmptyState } from '../empty-state';\nimport { PersistentPaginationWrapper } from '../persistent-pagination';\nimport { AvatarStack, StatusBadge, Skeleton } from '../ui';\n\nexport interface MeetingSchedulerDirectoryProps {\n  /** Endpoints prefix, default '' (same-origin `/api/meetings`). */\n  apiBaseUrl?: string;\n  /** Host-fetched seed — skips the client fetch when provided. */\n  initialData?: SchedulingLinksPayload | null;\n  /** `scope=all` — full-portal view (non-conforming links under \"other\"). */\n  includeAll?: boolean;\n  /** Rows per page; also the skeleton row count and the reserved rows-area height. */\n  pageSize?: number;\n  /** Rows link to `${bookingBasePath}/${slug}`. */\n  bookingBasePath?: string;\n  /** EmptyState CTA target for error/empty states (omit → no CTA). */\n  contactHref?: string;\n  className?: string;\n}\n\n/** Zone-aware \"next available\" label — resolved POST-mount (SSR renders the\n *  static caption only, so server and client first paint agree). */\nfunction useNextAvailableLabel(ms: number | null): string | null {\n  // The label is a pure function of `ms` and the viewer's zone — the ONLY\n  // reason it could not be computed during render was that `Intl` resolves a\n  // different zone on the server. `useIsHydrated` states that gate directly, so\n  // the label is derived rather than stored: no state, and no second render\n  // pass per row (this hook runs once per directory row).\n  const hydrated = useIsHydrated();\n  return useMemo(() => {\n    if (!hydrated || ms == null) return null;\n    try {\n      return new Intl.DateTimeFormat(undefined, {\n        weekday: 'short',\n        month: 'short',\n        day: 'numeric',\n        hour: 'numeric',\n        minute: '2-digit',\n      }).format(new Date(ms));\n    } catch {\n      return null;\n    }\n  }, [hydrated, ms]);\n}\n\nfunction DirectoryRow({\n  link,\n  audienceLabel,\n  bookingBasePath,\n}: {\n  link: SchedulingLink;\n  audienceLabel: string;\n  bookingBasePath: string;\n}) {\n  const nextLabel = useNextAvailableLabel(link.nextAvailableMs);\n\n  const subtitle = [\n    link.description,\n    link.durationsMinutes.length > 0 ? link.durationsMinutes.map(m => formatDurationCompact(m * 60)).join(' / ') : null,\n    link.kind === 'team' ? 'Team' : null,\n  ]\n    .filter(Boolean)\n    .join(' • ');\n\n  return (\n    <Link\n      href={`${bookingBasePath}/${link.slug}`}\n      prefetch={false}\n      className=\"block rounded-lg text-inherit no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n    >\n      <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3 transition-colors duration-150 hover:bg-ods-bg-hover\">\n        {/* Title leads at a fixed X; hosts live in the right meta cluster. */}\n        <div className=\"flex min-w-0 flex-1 flex-col justify-center text-left\">\n          <p className=\"truncate text-ods-text-primary text-h3\">{link.title}</p>\n          {subtitle && <p className=\"truncate text-ods-text-secondary text-h6\">{subtitle}</p>}\n        </div>\n\n        {/* No audience entity (scope=all \"other\" group) → no chip. */}\n        {audienceLabel && (\n          <StatusBadge text={`For ${audienceLabel}`} singleLine className=\"hidden shrink-0 sm:inline-flex\" />\n        )}\n\n        {/* Hosts stay visible on mobile too — the facepile is the row's \"who\n            you're booking with\" signal; only the chip and next-available meta\n            collapse on narrow widths. */}\n        <AvatarStack people={link.hosts} max={3} size=\"md\" className=\"flex shrink-0\" />\n\n        <div className=\"hidden w-44 shrink-0 flex-col items-end justify-center md:flex\">\n          <p className=\"text-ods-text-secondary text-h6\">Next available</p>\n          <p className=\"min-h-5 text-ods-text-primary text-h6\">\n            {link.nextAvailableMs == null ? 'No times published' : (nextLabel ?? ' ')}\n          </p>\n        </div>\n\n        {/* House boxed chevron (ChatTicketItem). */}\n        <span className=\"flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-card\">\n          <ChevronRight className=\"size-6 text-ods-text-secondary\" />\n        </span>\n      </div>\n    </Link>\n  );\n}\n\n/** Same-shell loading row (co-located-skeleton convention). */\nexport function MeetingSchedulerDirectoryRowSkeleton() {\n  return (\n    <div className=\"flex min-h-20 w-full items-center gap-4 rounded-lg border border-ods-border bg-ods-card px-4 py-3\">\n      <div className=\"flex min-w-0 flex-1 flex-col justify-center gap-1\">\n        <Skeleton className=\"h-5 w-1/2\" />\n        <Skeleton className=\"h-4 w-1/3\" />\n      </div>\n      <Skeleton className=\"hidden h-6 w-28 shrink-0 sm:block\" />\n      <Skeleton className=\"h-10 w-10 shrink-0 rounded-full\" />\n      <div className=\"hidden w-44 shrink-0 flex-col items-end gap-1 md:flex\">\n        <Skeleton className=\"h-4 w-24\" />\n        <Skeleton className=\"h-5 w-32\" />\n      </div>\n      <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n    </div>\n  );\n}\n\n/** Measured pagination-slot height (PersistentPaginationWrapper = 72px). */\nconst PAGINATION_SLOT_H = 'h-[4.5rem]';\n\nexport function MeetingSchedulerDirectory({\n  apiBaseUrl = '',\n  initialData = null,\n  includeAll = false,\n  pageSize = 6,\n  bookingBasePath = '/schedule-a-call',\n  contactHref,\n  className,\n}: MeetingSchedulerDirectoryProps) {\n  const [data, setData] = useState<SchedulingLinksPayload | null>(initialData);\n  const [isLoading, setIsLoading] = useState(initialData === null);\n  const [error, setError] = useState<string | null>(null);\n  const [page, setPage] = useState(1);\n\n  useEffect(() => {\n    if (initialData !== null) return undefined; // host-seeded — skip the client fetch\n    const controller = new AbortController();\n    let active = true;\n    const load = async () => {\n      setIsLoading(true);\n      setError(null);\n      try {\n        const qs = includeAll ? '?scope=all' : '';\n        const res = await contentFetch(`${apiBaseUrl}/api/meetings${qs}`, { signal: controller.signal });\n        if (!res.ok) throw new Error(`meetings ${res.status}`);\n        const payload = (await res.json()) as SchedulingLinksPayload;\n        if (active) setData(payload);\n      } catch (err) {\n        if (!active || (err instanceof DOMException && err.name === 'AbortError')) return;\n        setError(err instanceof Error ? err.message : 'Failed to fetch scheduling links');\n      } finally {\n        if (active) setIsLoading(false);\n      }\n    };\n    void load();\n    return () => {\n      active = false;\n      controller.abort();\n    };\n  }, [initialData, includeAll, apiBaseUrl]);\n\n  const rows = useMemo(() => {\n    const purposes = data?.purposes ?? [];\n    return purposes.flatMap(p => p.links.map(link => ({ link, audienceLabel: p.label })));\n  }, [data]);\n\n  const totalPages = pageCount(rows.length, pageSize);\n  const pageRows = rows.slice((page - 1) * pageSize, page * pageSize);\n\n  // Reserved rows-area height: pageSize × 80px rows + (pageSize−1) × 16px gaps.\n  const rowsMinHeight = pageSize * 80 + (pageSize - 1) * 16;\n\n  if (isLoading && !data) {\n    return (\n      <div className={cn('flex flex-col', className)}>\n        <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n          {Array.from({ length: pageSize }, (_, i) => (\n            <MeetingSchedulerDirectoryRowSkeleton key={i} />\n          ))}\n        </div>\n        <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n          <Skeleton className=\"h-10 w-64\" />\n        </div>\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n        <EmptyState\n          type=\"generic\"\n          title=\"We couldn't load available call times\"\n          description=\"Something went wrong on our side. Try again in a minute, or reach us directly.\"\n          {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n        />\n      </div>\n    );\n  }\n\n  if (rows.length === 0) {\n    return (\n      <div className={cn('flex flex-col justify-center', className)} style={{ minHeight: rowsMinHeight + 104 }}>\n        <EmptyState\n          type=\"generic\"\n          title=\"No call times are published right now\"\n          description=\"We're updating our calendars. Reach us directly and we'll set something up.\"\n          {...(contactHref ? { ctaText: 'Contact us', ctaHref: contactHref } : {})}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn('flex flex-col', className)}>\n      <div className=\"flex w-full flex-col gap-4\" style={{ minHeight: rowsMinHeight }}>\n        {pageRows.map(({ link, audienceLabel }) => (\n          <DirectoryRow key={link.id} link={link} audienceLabel={audienceLabel} bookingBasePath={bookingBasePath} />\n        ))}\n      </div>\n      {/* House pagination block — dims in place during refetches; the slot\n          holds its measured height even at ≤1 page (where the inner\n          UnifiedPagination returns null). */}\n      <div className={cn('mt-8 flex w-full items-center justify-center', PAGINATION_SLOT_H)}>\n        {totalPages > 1 && (\n          <PersistentPaginationWrapper\n            isLoading={isLoading}\n            currentPage={page}\n            totalPages={totalPages}\n            onPageChange={setPage}\n            variant=\"blog\"\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n"]}