{"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/context-panel.tsx","../../../src/components/meeting-scheduler/slot-picker.tsx","../../../src/components/meeting-scheduler/booking-form.tsx","../../../src/components/meeting-scheduler/confirmation.tsx","../../../src/components/meeting-scheduler/directory.tsx"],"names":["useCallback","jsx","jsxs","useMemo","ChevronRight","useState"],"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 */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '../../utils/cn'\nimport { formatDurationCompact } from '../../utils/format'\nimport { Button } from '../ui'\nimport { useHumanitySignals } from '../../hooks/use-humanity-signals'\nimport { useMeetingBooking } from '../../hooks/use-meeting-booking'\nimport {\n  isSupportedFormField,\n  type BookingConfirmation,\n  type MeetingAvailability,\n  type MeetingBookingErrorCode,\n  type MeetingHost,\n} from '../../schemas/meeting-booking-schema'\nimport { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel'\nimport { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker'\nimport { BookingForm } from './booking-form'\nimport { Confirmation } from './confirmation'\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\ntype Step = 'slot' | 'details' | 'confirmed'\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\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 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 * Both heights come from {@link MEETING_SCHEDULER_H}; 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  'rounded-md border border-ods-border bg-ods-card overflow-hidden',\n  'md:flex md:flex-col',\n  MEETING_SCHEDULER_H,\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  'rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)] flex flex-col items-start gap-[var(--spacing-system-m)]',\n  'md:justify-center md:overflow-y-auto',\n  MEETING_SCHEDULER_H,\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}: 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  // 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  const [timezone, setTimezone] = useState<string | null>(displayTimezone ?? null)\n  useEffect(() => {\n    if (displayTimezone) return\n    try {\n      setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC')\n    } catch {\n      setTimezone('UTC')\n    }\n  }, [displayTimezone])\n\n  const [step, setStep] = useState<Step>('slot')\n  const [durationMs, setDurationMs] = useState<number | null>(null)\n  const [selectedDay, 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\n  // Reset the machine when the host switches links.\n  useEffect(() => {\n    setStep('slot')\n    setDurationMs(null)\n    setSelectedDay(null)\n    setSelectedSlot(null)\n    setConfirmation(null)\n    setBookingError(null)\n    autoAdvanceCount.current = 0\n  }, [meetingId])\n\n  const durations = 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  useEffect(() => {\n    if (durationMs == null && durations.length > 0) setDurationMs(durations[0])\n  }, [durationMs, durations])\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  useEffect(() => {\n    if (step !== 'slot' || !timezone) return\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      if (selectedDay && !selectedDay.startsWith(monthPrefix)) setSelectedDay(null)\n      return\n    }\n    const dayKeys = new Set(inMonth.map((ms) => dayKeyInZone(ms, timezone)))\n    if (selectedDay && dayKeys.has(selectedDay)) return\n    setSelectedDay(dayKeyInZone(inMonth[0], timezone))\n  }, [step, timezone, slots, selectedDay, monthOffset])\n\n  // Fully-booked month with more ahead → auto-advance (bounded, ≤3 hops)\n  // before showing the empty state.\n  const autoAdvanceCount = useRef(0)\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    ) {\n      autoAdvanceCount.current += 1\n      setMonthOffset(monthOffset + 1)\n    }\n  }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset])\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  const handleSubmit = useCallback(\n    async (payload: Record<string, unknown>) => {\n      setBookingError(null)\n      const result = await book(payload)\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        resetSignals()\n        void refetchAvailability()\n      }\n    },\n    [book, onBooked, refetchAvailability, resetSignals],\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  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={cn(CARD_CLASS, className)}>\n        <div className={CARD_INNER_CLASS}>\n          <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />\n          <div className={ACTION_PANEL_CLASS}>\n            <SlotPickerSkeleton monthOffset={monthOffset} />\n          </div>\n        </div>\n      </div>\n    )\n  }\n\n  if (availabilityError || !availability) {\n    return (\n      <div\n        className={cn(CARD_DEGRADED_CLASS, className)}\n      >\n        <p className=\"text-h6 text-ods-text-secondary\">\n          We couldn&apos;t load available call times. Please try again shortly.\n        </p>\n        {escapeHatch}\n      </div>\n    )\n  }\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 (\n      <div\n        className={cn(CARD_DEGRADED_CLASS, className)}\n      >\n        <p className=\"text-h6 text-ods-text-secondary\">This meeting type is booked directly on HubSpot.</p>\n        {escapeHatch}\n      </div>\n    )\n  }\n\n  // ---- the card ------------------------------------------------------------\n\n  return (\n    <div className={cn(CARD_CLASS, className)}>\n      <div className={CARD_INNER_CLASS}>\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            if (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          onBack={step === 'details' ? backToSlot : onBack}\n          locked={step !== 'slot'}\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        {/* 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={ACTION_PANEL_CLASS}>\n          {step === 'confirmed' && confirmation && timezone ? (\n            <div className={cn('flex flex-1 flex-col', PANEL_STEP_CLASS)}>\n              <Confirmation confirmation={confirmation} timezone={timezone} />\n            </div>\n          ) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (\n            <div className={cn('flex flex-1 flex-col gap-[var(--spacing-system-m)]', PANEL_STEP_CLASS)}>\n              {/* Top-aligned, and no back edge of its own: the ONE back edge\n                  lives in the context panel at every step (see `contextBack`),\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              <p className=\"text-h4 text-ods-text-primary\">\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              {bookingError && bookingError !== 'SLOT_TAKEN' && (\n                <div className=\"flex flex-col items-start gap-[var(--spacing-system-xs)]\">\n                  <p className=\"text-h6 text-ods-error\">\n                    {bookingError === 'TEMPORARILY_UNAVAILABLE'\n                      ? 'Scheduling is briefly unavailable — please try again in a minute.'\n                      : bookingError === 'MEETING_UNAVAILABLE'\n                        ? 'This meeting type has reached its booking limit for today — try another time or contact us.'\n                        : bookingError === 'LINK_GONE'\n                          ? 'This meeting type is no longer available.'\n                          : 'Please double-check your details and try again.'}\n                  </p>\n                  {escapeHatch}\n                </div>\n              )}\n              <BookingForm\n                availability={availability}\n                meetingId={meetingId}\n                startTimeMs={selectedSlot}\n                durationMs={durationMs}\n                timezone={timezone}\n                isSubmitting={isSubmitting}\n                onSubmit={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                <p className=\"px-[var(--spacing-system-l)] pt-[var(--spacing-system-l)] text-h6 text-ods-error lg:p-0\">\n                  That time is no longer available — if you just submitted, check your email for a confirmation\n                  before rebooking.\n                </p>\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                    setSelectedDay(null)\n                    setSelectedSlot(null)\n                    setMonthOffset(o)\n                  }}\n                  selectedSlot={selectedSlot}\n                  onSelectSlot={(ms) => {\n                    setSelectedSlot(ms)\n                    setStep('details')\n                  }}\n                  selectedDay={selectedDay}\n                  onSelectDay={setSelectedDay}\n                  isLoading={isLoadingAvailability}\n                />\n              ) : durations.length === 0 && !isFetchingAvailability ? (\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.\n                <div className=\"flex flex-col items-start gap-[var(--spacing-system-m)]\">\n                  <p className=\"text-h6 text-ods-text-secondary\">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\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 { contentFetch } from '../utils/embed-content-fetch'\nimport { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention'\nimport type {\n  BookingConfirmation,\n  MeetingAvailability,\n  MeetingBookingErrorCode,\n} from '../schemas/meeting-booking-schema'\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 deliberately short: HubSpot slots go stale in about a minute,\n * and a slot that is gone by the time it is clicked costs a booking. Fresh\n * enough to trust, cached enough not to blink.\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\nconst KNOWN_CODES: MeetingBookingErrorCode[] = [\n  'SLOT_TAKEN',\n  'VALIDATION',\n  'LINK_GONE',\n  'TEMPORARILY_UNAVAILABLE',\n  'MEETING_UNAVAILABLE',\n]\n\n/** ~half HubSpot's own slot volatility window — see the docblock. */\nconst AVAILABILITY_STALE_MS = 30_000\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\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 = (KNOWN_CODES as 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  const submittingRef = useRef(false)\n  const { mutateAsync } = bookMutation\n  const book = useCallback(\n    async (payload: Record<string, unknown>): Promise<BookingResult> => {\n      if (submittingRef.current) return { ok: false, code: 'VALIDATION', message: 'Already submitting' }\n      submittingRef.current = true\n      try {\n        return await mutateAsync(payload)\n      } finally {\n        submittingRef.current = false\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 */\nexport type MeetingBookingErrorCode =\n  | 'SLOT_TAKEN'\n  | 'VALIDATION'\n  | 'LINK_GONE'\n  | 'TEMPORARILY_UNAVAILABLE'\n  | 'MEETING_UNAVAILABLE'\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox'] as const\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number]\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n  return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type)\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 */\nexport function makeBookingSchema(\n  formFields: MeetingFormField[],\n  legalConsent: MeetingLegalConsent | null,\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    let validator: z.ZodTypeAny\n    switch (field.type as SupportedFormFieldType) {\n      case 'checkbox':\n        validator = z.boolean()\n        break\n      case 'select':\n      case 'radio':\n        validator = z\n          .string()\n          .refine((v) => !v || (field.options ?? []).includes(v), {\n            message: `Please choose a valid option for ${field.label}`,\n          })\n        break\n      case 'textarea':\n        validator = z.string().max(5000, { message: `${field.label} is too long` })\n        break\n      case 'text':\n      default:\n        validator = z.string().max(1000, { message: `${field.label} is too long` })\n        break\n    }\n    if (field.required) {\n      validator =\n        field.type === 'checkbox'\n          ? z.literal(true, { message: `${field.label} is required` })\n          : (validator as z.ZodString).min(1, { message: `${field.label} is required` })\n    } else if (field.type !== 'checkbox') {\n      validator = (validator as z.ZodString).optional().or(z.literal(''))\n    } else {\n      validator = z.boolean().optional()\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 z\n    .object({\n      meetingId: z.string().min(1),\n      startTimeMs: z.number().int().positive(),\n      durationMs: z.number().int().positive(),\n      firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n      lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n      email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n      timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid 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\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>\n","'use client'\n\nimport { useMemo } from 'react'\nimport { SquareAvatar, AvatarStack, Button, Skeleton, Autocomplete } from '../ui'\nimport { BackButton } from '../layout/back-button'\nimport { cn } from '../../utils/cn'\nimport { formatDurationCompact } from '../../utils/format'\nimport type { MeetingHost } from '../../schemas/meeting-booking-schema'\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  /** 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 =\n  '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  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 flex-col min-w-0\">\n                      <p className=\"text-h4 text-ods-text-primary truncate\">{host.name}</p>\n                      {host.title && <p className=\"text-h6 text-ods-text-secondary truncate\">{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-h6 text-ods-text-secondary\">{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-h6 text-ods-text-secondary\">\n                    {formatDurationCompact(selectedDurationMs / 1000)} call\n                  </p>\n                )\n              : null}\n          </div>\n\n          {title && <p className=\"text-h3 text-ods-text-primary\">{title}</p>}\n          {description && <p className=\"text-h6 text-ods-text-secondary\">{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                    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                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 { useCallback, useMemo } from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport { Button, DatePickerCalendar, Skeleton } from '../ui'\nimport { cn } from '../../utils/cn'\nimport { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention'\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\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  'flex-1 min-h-0',\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\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(\n    (day: Date) => !slotsByDay?.has(calendarDayKey(day)),\n    [slotsByDay],\n  )\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}: 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                    onClick={() => onSelectSlot(ms)}\n                    className={CHIP_CLASS}\n                  >\n                    {timeLabelInZone(ms, timezone)}\n                  </Button>\n                )\n              })}\n            </div>\n          </>\n        ) : monthHasSlots ? (\n          <p className=\"text-h6 text-ods-text-secondary\">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-h6 text-ods-text-secondary\">\n              No available times in {monthLabelFor(monthOffset)}.\n            </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\nimport { useMemo } from 'react'\nimport { useForm, Controller } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport {\n  makeBookingSchema,\n  isSupportedFormField,\n  type MeetingAvailability,\n  type MeetingBookingPayload,\n} from '../../schemas/meeting-booking-schema'\nimport {\n  Button,\n  FieldWrapper,\n  Input,\n  Textarea,\n  Label,\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n  Checkbox,\n  RadioGroup,\n  RadioGroupItem,\n} from '../ui'\nimport { HoneypotField } from '../ui/honeypot-field'\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 */\n\nexport interface BookingFormProps {\n  availability: MeetingAvailability\n  meetingId: string\n  startTimeMs: number\n  durationMs: number\n  /** IANA zone the confirmation/invite should render in (parent-resolved). */\n  timezone: string\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: React.Ref<HTMLInputElement>; name: string }\n  getSignals: () => Record<string, string | number>\n}\n\nexport function BookingForm({\n  availability,\n  meetingId,\n  startTimeMs,\n  durationMs,\n  timezone,\n  isSubmitting,\n  onSubmit,\n  honeypotInputProps,\n  getSignals,\n}: BookingFormProps) {\n  const { formFields, legalConsent } = availability\n  const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields])\n  const schema = useMemo(() => makeBookingSchema(supportedFields, legalConsent), [supportedFields, legalConsent])\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    formState: { errors },\n  } = useForm<MeetingBookingPayload>({\n    resolver: zodResolver(schema),\n    defaultValues: {\n      meetingId,\n      startTimeMs,\n      durationMs,\n      timezone,\n      locale: typeof navigator !== 'undefined' ? navigator.language : undefined,\n      firstName: '',\n      lastName: '',\n      email: '',\n      formFields: {},\n      legalConsentResponses: (legalConsent?.communicationConsentCheckboxes ?? []).map((c) => ({\n        communicationTypeId: c.communicationTypeId,\n        consented: false,\n      })),\n    },\n  })\n\n  const submit = handleSubmit(async (data) => {\n    await onSubmit({ ...data, meetingId, startTimeMs, durationMs, timezone, ...getSignals() })\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  // Field chrome mirrors ContactForm 1:1 (`contact/contact-form.tsx`) — the\n  // booking form must be indistinguishable from every other form in the app.\n  const inputClass = 'bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-11 md:h-12'\n  // One step ABOVE the `spacing system/m` the design names (16/24 instead of\n  // 12/16), because the field messages hang out of flow: they need ~16px on a\n  // phone and ~20 on desktop of clear space under the control, and `m` leaves\n  // 12/16 — four pixels short at both ends, so an error would print over the\n  // next field's label. The design has no error state drawn; this is the\n  // smallest ODS step that houses it.\n  const FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]'\n\n  return (\n    <form onSubmit={submit} className={FORM_STACK} noValidate>\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          not by an asterisk the design does not draw. */}\n      <FieldWrapper label=\"Email\" htmlFor=\"ms-email\" error={errors.email?.message}>\n        <Input\n          id=\"ms-email\"\n          type=\"email\"\n          required\n          autoComplete=\"email\"\n          placeholder=\"jane@company.com\"\n          aria-invalid={!!errors.email}\n          className={inputClass}\n          {...register('email')}\n        />\n      </FieldWrapper>\n\n      <div className=\"grid grid-cols-2 gap-[var(--spacing-system-m)]\">\n        <FieldWrapper label=\"First Name\" htmlFor=\"ms-first-name\" error={errors.firstName?.message}>\n          <Input\n            id=\"ms-first-name\"\n            required\n            autoComplete=\"given-name\"\n            placeholder=\"Jane\"\n            aria-invalid={!!errors.firstName}\n            className={inputClass}\n            {...register('firstName')}\n          />\n        </FieldWrapper>\n        <FieldWrapper label=\"Last Name\" htmlFor=\"ms-last-name\" error={errors.lastName?.message}>\n          <Input\n            id=\"ms-last-name\"\n            required\n            autoComplete=\"family-name\"\n            placeholder=\"Doe\"\n            aria-invalid={!!errors.lastName}\n            className={inputClass}\n            {...register('lastName')}\n          />\n        </FieldWrapper>\n      </div>\n\n      {supportedFields.map((field) => (\n        <FieldWrapper\n          key={field.name}\n          label={field.label}\n          htmlFor={`ms-q-${field.name}`}\n          error={fieldError(field.name)}\n        >\n          {field.type === 'textarea' && (\n            <Textarea\n              id={`ms-q-${field.name}`}\n              className=\"bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3\"\n              {...register(`formFields.${field.name}` as never)}\n            />\n          )}\n          {field.type === 'text' && (\n            <Input\n              id={`ms-q-${field.name}`}\n              className=\"bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-11 md:h-12\"\n              {...register(`formFields.${field.name}` as never)}\n            />\n          )}\n          {(field.type === 'select' || field.type === 'radio') && (\n            <Controller\n              control={control}\n              name={`formFields.${field.name}` as never}\n              render={({ field: rhf }) =>\n                field.type === 'select' ? (\n                  <Select value={(rhf.value as string) ?? ''} onValueChange={rhf.onChange}>\n                    <SelectTrigger id={`ms-q-${field.name}`}>\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                  <RadioGroup value={(rhf.value as string) ?? ''} onValueChange={rhf.onChange}>\n                    {(field.options ?? []).map((opt) => (\n                      <div key={opt} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n                        <RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />\n                        <Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>\n                      </div>\n                    ))}\n                  </RadioGroup>\n                )\n              }\n            />\n          )}\n          {field.type === 'checkbox' && (\n            <Controller\n              control={control}\n              name={`formFields.${field.name}` as never}\n              render={({ field: rhf }) => (\n                // The question's Label above already carries the text and\n                // binds to this id — a second inline label would double the\n                // visible text AND the accessible name.\n                <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n                  <Checkbox\n                    id={`ms-q-${field.name}`}\n                    checked={Boolean(rhf.value)}\n                    onCheckedChange={(v) => rhf.onChange(v === true)}\n                  />\n                </div>\n              )}\n            />\n          )}\n        </FieldWrapper>\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 as string | undefined}>\n        <div className=\"flex flex-col gap-[var(--spacing-system-xs)] border border-ods-border rounded-md p-[var(--spacing-system-m)]\">\n          {/* GDPR surface — HubSpot's consent copy rendered VERBATIM, never edited. */}\n          <p className=\"text-h6 text-ods-text-secondary\">{legalConsent.processingConsentText}</p>\n          {legalConsent.communicationConsentText && (\n            <p className=\"text-h6 text-ods-text-secondary\">{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                    <div key={box.communicationTypeId} className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n                      <Checkbox\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 === true } : r,\n                            ),\n                          )\n                        }\n                      />\n                      <Label htmlFor={`ms-consent-${box.communicationTypeId}`}>{box.label}</Label>\n                    </div>\n                  )\n                })}\n              </>\n            )}\n          />\n          {legalConsent.privacyPolicyText && (\n            <p className=\"text-h6 text-ods-text-secondary\">{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      <div className=\"flex\">\n        <Button type=\"submit\" loading={isSubmitting} disabled={isSubmitting}>\n          Confirm Booking\n        </Button>\n      </div>\n    </form>\n  )\n}\n","'use client'\n\nimport { formatDurationCompact } from '../../utils/format'\nimport type { BookingConfirmation } from '../../schemas/meeting-booking-schema'\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-h3 text-ods-text-primary\">You&apos;re booked</h3>\n      <p className=\"text-h5 text-ods-text-primary\">{confirmation.title}</p>\n      <p className=\"text-h6 text-ods-text-secondary\">\n        {when} · {formatDurationCompact(confirmation.durationMs / 1000)}\n      </p>\n      <p className=\"text-h6 text-ods-text-secondary\">A calendar invite is on its way to your email.</p>\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 { useEffect, useMemo, useState } from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport Link from '../../embed-shims/next-link'\nimport { contentFetch } from '../../utils/embed-content-fetch'\nimport { formatDurationCompact } from '../../utils/format'\nimport { cn } from '../../utils/cn'\nimport { AvatarStack, StatusBadge, Skeleton } from '../ui'\nimport { EmptyState } from '../empty-state'\nimport { PersistentPaginationWrapper } from '../persistent-pagination'\nimport type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema'\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  const [label, setLabel] = useState<string | null>(null)\n  useEffect(() => {\n    if (ms == null) return\n    try {\n      setLabel(\n        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      )\n    } catch {\n      setLabel(null)\n    }\n  }, [ms])\n  return label\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 no-underline text-inherit focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n    >\n      <div className=\"flex w-full items-center gap-4 px-4 py-3 min-h-20 rounded-lg border border-ods-border bg-ods-card 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=\"text-h3 text-ods-text-primary truncate\">{link.title}</p>\n          {subtitle && <p className=\"text-h6 text-ods-text-secondary truncate\">{subtitle}</p>}\n        </div>\n\n        {/* No audience entity (scope=all \"other\" group) → no chip. */}\n        {audienceLabel && (\n          <StatusBadge text={`For ${audienceLabel}`} singleLine className=\"shrink-0 hidden 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 md:flex w-44 shrink-0 flex-col items-end justify-center\">\n          <p className=\"text-h6 text-ods-text-secondary\">Next available</p>\n          <p className=\"text-h6 text-ods-text-primary min-h-5\">\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 w-full items-center gap-4 px-4 py-3 min-h-20 rounded-lg border border-ods-border bg-ods-card\">\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 sm:block h-6 w-28 shrink-0\" />\n      <Skeleton className=\"h-10 w-10 shrink-0 rounded-full\" />\n      <div className=\"hidden md:flex w-44 shrink-0 flex-col items-end gap-1\">\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 // 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 = Math.max(1, Math.ceil(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"]}