@flamingo-stack/openframe-frontend-core 0.0.561-1852.4997.2 → 0.0.561

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/{chunk-KGFCHGCW.js → chunk-44U25GD5.js} +2 -2
  2. package/dist/{chunk-DCB4EBA5.cjs → chunk-4LHEQMOV.cjs} +3 -3
  3. package/dist/chunk-4LHEQMOV.cjs.map +1 -0
  4. package/dist/{chunk-PYN65LCP.js → chunk-5AEHCGQV.js} +6 -6
  5. package/dist/{chunk-K4UCFJNL.cjs → chunk-AGQ4VTUX.cjs} +29 -29
  6. package/dist/{chunk-K4UCFJNL.cjs.map → chunk-AGQ4VTUX.cjs.map} +1 -1
  7. package/dist/{chunk-HI3A5HFI.js → chunk-B6TM3QAL.js} +4 -4
  8. package/dist/{chunk-NISIZABN.cjs → chunk-CR2CMLTQ.cjs} +4 -4
  9. package/dist/{chunk-NISIZABN.cjs.map → chunk-CR2CMLTQ.cjs.map} +1 -1
  10. package/dist/{chunk-K535WIFS.cjs → chunk-D3OY4CJU.cjs} +3 -3
  11. package/dist/{chunk-K535WIFS.cjs.map → chunk-D3OY4CJU.cjs.map} +1 -1
  12. package/dist/{chunk-RVHAX7VP.cjs → chunk-DAVVVBNL.cjs} +26 -26
  13. package/dist/{chunk-RVHAX7VP.cjs.map → chunk-DAVVVBNL.cjs.map} +1 -1
  14. package/dist/{chunk-V5VJTNOG.cjs → chunk-DHRJFUBR.cjs} +11 -11
  15. package/dist/{chunk-V5VJTNOG.cjs.map → chunk-DHRJFUBR.cjs.map} +1 -1
  16. package/dist/{chunk-LNWLK4TC.cjs → chunk-EK346HD7.cjs} +39 -39
  17. package/dist/{chunk-LNWLK4TC.cjs.map → chunk-EK346HD7.cjs.map} +1 -1
  18. package/dist/{chunk-SKQ7AMUD.cjs → chunk-FSWJCGRJ.cjs} +7 -7
  19. package/dist/{chunk-SKQ7AMUD.cjs.map → chunk-FSWJCGRJ.cjs.map} +1 -1
  20. package/dist/{chunk-3IBCKS3I.js → chunk-GKGQ4DWE.js} +121 -49
  21. package/dist/chunk-GKGQ4DWE.js.map +1 -0
  22. package/dist/{chunk-XTDUFU5O.cjs → chunk-GWRQ5EEI.cjs} +954 -882
  23. package/dist/{chunk-XTDUFU5O.cjs.map → chunk-GWRQ5EEI.cjs.map} +1 -1
  24. package/dist/{chunk-IJ6HBN2J.cjs → chunk-GYC3OTBI.cjs} +9 -9
  25. package/dist/{chunk-IJ6HBN2J.cjs.map → chunk-GYC3OTBI.cjs.map} +1 -1
  26. package/dist/{chunk-SFU6A36Q.js → chunk-JCQUWIPT.js} +3 -3
  27. package/dist/{chunk-NZNKHIFA.js → chunk-KRYLVEXU.js} +2 -2
  28. package/dist/{chunk-LSWJ5QYP.js → chunk-KWNVQZ5Y.js} +4 -4
  29. package/dist/{chunk-ZD5QQJH4.js → chunk-MLSDBNKT.js} +2 -2
  30. package/dist/{chunk-NTTSXF7Z.js → chunk-NGY4KET4.js} +2 -2
  31. package/dist/{chunk-O45VULK4.js → chunk-OCJG3X4U.js} +2 -2
  32. package/dist/{chunk-KKIAKLI6.js → chunk-ONDI64M3.js} +2 -2
  33. package/dist/{chunk-FMQO7OMJ.js → chunk-QGQ26NEE.js} +2 -2
  34. package/dist/{chunk-RSZXB6J2.js → chunk-RDKJCZHY.js} +3 -3
  35. package/dist/chunk-RDKJCZHY.js.map +1 -0
  36. package/dist/{chunk-ELFXMLSX.cjs → chunk-SUPBFRUK.cjs} +5 -5
  37. package/dist/{chunk-ELFXMLSX.cjs.map → chunk-SUPBFRUK.cjs.map} +1 -1
  38. package/dist/{chunk-BODITFZI.cjs → chunk-UJ7MSY6F.cjs} +31 -31
  39. package/dist/{chunk-BODITFZI.cjs.map → chunk-UJ7MSY6F.cjs.map} +1 -1
  40. package/dist/{chunk-INN323YY.cjs → chunk-UV53Z64O.cjs} +14 -14
  41. package/dist/{chunk-INN323YY.cjs.map → chunk-UV53Z64O.cjs.map} +1 -1
  42. package/dist/{chunk-TCQ3VL5R.cjs → chunk-WCM4BGHO.cjs} +97 -97
  43. package/dist/{chunk-TCQ3VL5R.cjs.map → chunk-WCM4BGHO.cjs.map} +1 -1
  44. package/dist/{chunk-DQI2S2SY.js → chunk-YUAKZNK5.js} +6 -6
  45. package/dist/{chunk-5XPYQ7NU.cjs → chunk-ZENRHE55.cjs} +62 -62
  46. package/dist/{chunk-5XPYQ7NU.cjs.map → chunk-ZENRHE55.cjs.map} +1 -1
  47. package/dist/{chunk-CLQ6L4JG.js → chunk-ZJ7RI62F.js} +2 -2
  48. package/dist/components/calendar.cjs +81 -4
  49. package/dist/components/calendar.cjs.map +1 -1
  50. package/dist/components/calendar.js +81 -4
  51. package/dist/components/calendar.js.map +1 -1
  52. package/dist/components/case-studies/index.cjs +9 -9
  53. package/dist/components/case-studies/index.js +3 -3
  54. package/dist/components/chat/index.cjs +3 -3
  55. package/dist/components/chat/index.js +2 -2
  56. package/dist/components/contact/index.cjs +4 -4
  57. package/dist/components/contact/index.js +3 -3
  58. package/dist/components/docs/index.cjs +6 -6
  59. package/dist/components/docs/index.js +5 -5
  60. package/dist/components/embeds/index.cjs +4 -4
  61. package/dist/components/embeds/index.js +3 -3
  62. package/dist/components/faq/index.cjs +4 -4
  63. package/dist/components/faq/index.js +3 -3
  64. package/dist/components/features/index.cjs +5 -3
  65. package/dist/components/features/index.cjs.map +1 -1
  66. package/dist/components/features/index.d.ts +1 -1
  67. package/dist/components/features/index.d.ts.map +1 -1
  68. package/dist/components/features/index.js +4 -2
  69. package/dist/components/features/use-walkthrough-video.d.ts +1 -1
  70. package/dist/components/features/use-walkthrough-video.d.ts.map +1 -1
  71. package/dist/components/features/{floating-walkthrough-video.d.ts → walkthrough-video.d.ts} +61 -7
  72. package/dist/components/features/walkthrough-video.d.ts.map +1 -0
  73. package/dist/components/help-center-pages/index.cjs +23 -23
  74. package/dist/components/help-center-pages/index.js +14 -14
  75. package/dist/components/index.cjs +144 -140
  76. package/dist/components/index.cjs.map +1 -1
  77. package/dist/components/index.js +16 -12
  78. package/dist/components/index.js.map +1 -1
  79. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  80. package/dist/components/meeting-scheduler/context-panel.d.ts +24 -6
  81. package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
  82. package/dist/components/meeting-scheduler/index.cjs +529 -425
  83. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  84. package/dist/components/meeting-scheduler/index.d.ts +54 -1
  85. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  86. package/dist/components/meeting-scheduler/index.js +507 -403
  87. package/dist/components/meeting-scheduler/index.js.map +1 -1
  88. package/dist/components/meeting-scheduler/slot-picker.d.ts +35 -31
  89. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  90. package/dist/components/navigation/index.cjs +3 -3
  91. package/dist/components/navigation/index.js +2 -2
  92. package/dist/components/onboarding-guides/index.cjs +6 -6
  93. package/dist/components/onboarding-guides/index.js +5 -5
  94. package/dist/components/related-content/index.cjs +4 -4
  95. package/dist/components/related-content/index.js +3 -3
  96. package/dist/components/tickets/index.cjs +7 -7
  97. package/dist/components/tickets/index.js +6 -6
  98. package/dist/components/ui/date-picker.d.ts +62 -1
  99. package/dist/components/ui/date-picker.d.ts.map +1 -1
  100. package/dist/components/ui/field-wrapper.d.ts +7 -0
  101. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  102. package/dist/components/ui/file-manager/index.cjs +36 -36
  103. package/dist/components/ui/file-manager/index.js +1 -1
  104. package/dist/components/ui/index.cjs +5 -3
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.js +4 -2
  107. package/dist/hooks/use-meeting-booking.d.ts +31 -8
  108. package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
  109. package/dist/index.cjs +7 -3
  110. package/dist/index.cjs.map +1 -1
  111. package/dist/index.js +6 -2
  112. package/package.json +1 -1
  113. package/src/components/features/{.floating-walkthrough-video.md → .walkthrough-video.md} +8 -5
  114. package/src/components/features/board/types.ts +1 -1
  115. package/src/components/features/index.ts +1 -1
  116. package/src/components/features/use-walkthrough-video.ts +1 -1
  117. package/src/components/features/{floating-walkthrough-video.tsx → walkthrough-video.tsx} +108 -22
  118. package/src/components/meeting-scheduler/booking-form.tsx +60 -51
  119. package/src/components/meeting-scheduler/confirmation.tsx +1 -1
  120. package/src/components/meeting-scheduler/context-panel.tsx +166 -68
  121. package/src/components/meeting-scheduler/index.tsx +227 -81
  122. package/src/components/meeting-scheduler/slot-picker.tsx +275 -127
  123. package/src/components/ui/date-picker.tsx +200 -47
  124. package/src/components/ui/field-wrapper.tsx +9 -2
  125. package/src/hooks/use-meeting-booking.ts +97 -73
  126. package/dist/chunk-3IBCKS3I.js.map +0 -1
  127. package/dist/chunk-7J4DGFHZ.js +0 -88
  128. package/dist/chunk-7J4DGFHZ.js.map +0 -1
  129. package/dist/chunk-DCB4EBA5.cjs.map +0 -1
  130. package/dist/chunk-MKPJYEOR.cjs +0 -88
  131. package/dist/chunk-MKPJYEOR.cjs.map +0 -1
  132. package/dist/chunk-RSZXB6J2.js.map +0 -1
  133. package/dist/components/features/floating-walkthrough-video.d.ts.map +0 -1
  134. /package/dist/{chunk-KGFCHGCW.js.map → chunk-44U25GD5.js.map} +0 -0
  135. /package/dist/{chunk-PYN65LCP.js.map → chunk-5AEHCGQV.js.map} +0 -0
  136. /package/dist/{chunk-HI3A5HFI.js.map → chunk-B6TM3QAL.js.map} +0 -0
  137. /package/dist/{chunk-SFU6A36Q.js.map → chunk-JCQUWIPT.js.map} +0 -0
  138. /package/dist/{chunk-NZNKHIFA.js.map → chunk-KRYLVEXU.js.map} +0 -0
  139. /package/dist/{chunk-LSWJ5QYP.js.map → chunk-KWNVQZ5Y.js.map} +0 -0
  140. /package/dist/{chunk-ZD5QQJH4.js.map → chunk-MLSDBNKT.js.map} +0 -0
  141. /package/dist/{chunk-NTTSXF7Z.js.map → chunk-NGY4KET4.js.map} +0 -0
  142. /package/dist/{chunk-O45VULK4.js.map → chunk-OCJG3X4U.js.map} +0 -0
  143. /package/dist/{chunk-KKIAKLI6.js.map → chunk-ONDI64M3.js.map} +0 -0
  144. /package/dist/{chunk-FMQO7OMJ.js.map → chunk-QGQ26NEE.js.map} +0 -0
  145. /package/dist/{chunk-DQI2S2SY.js.map → chunk-YUAKZNK5.js.map} +0 -0
  146. /package/dist/{chunk-CLQ6L4JG.js.map → chunk-ZJ7RI62F.js.map} +0 -0
@@ -11,13 +11,16 @@
11
11
  * their own backend — the book route is IP-rate-limited and bot-gated).
12
12
  *
13
13
  * LAYOUT (Calendly-anatomy, conversion-first): one bordered card, two
14
- * panels. Left/top — the CONTEXT panel: host identity (avatar + name +
15
- * title from `availability.hosts`), meeting title/description, duration
16
- * chips (the duration choice lives here, NOT as a wizard step — the visitor
17
- * always lands straight on the calendar), and the resolved timezone.
14
+ * panels. Left/top — the CONTEXT panel: an optional host-level back edge
15
+ * (`onBack`), host identity (avatar + name + title from
16
+ * `availability.hosts`), meeting title/description, duration chips (the
17
+ * duration choice lives here, NOT as a wizard step — the visitor always
18
+ * lands straight on the calendar), and the resolved timezone.
18
19
  * Right/bottom — the ACTION panel: calendar + time slots (first available
19
20
  * day auto-selected so slots are visible immediately), then the details
20
- * form, then the confirmation. Panels stack on mobile.
21
+ * form, then the confirmation. On the slot step that panel splits again —
22
+ * calendar column, then times column — each carrying its own heading (owned
23
+ * by `SlotPicker`, so loading and loaded agree). Panels stack on mobile.
21
24
  *
22
25
  * Two modes, one component:
23
26
  * - SSR mode: pass `initialAvailability` (host-fetched) — the
@@ -36,7 +39,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
36
39
  import { cn } from '../../utils/cn'
37
40
  import { formatDurationCompact } from '../../utils/format'
38
41
  import { Button } from '../ui'
39
- import { BackButton } from '../layout/back-button'
40
42
  import { useHumanitySignals } from '../../hooks/use-humanity-signals'
41
43
  import { useMeetingBooking } from '../../hooks/use-meeting-booking'
42
44
  import {
@@ -68,12 +70,149 @@ export interface HubSpotMeetingSchedulerProps {
68
70
  displayTimezone?: string
69
71
  /** The link's public HubSpot booking URL — the "Open in HubSpot" escape hatch target. */
70
72
  fallbackUrl?: string
73
+ /**
74
+ * Host-level exit, rendered as a back edge at the top of the context panel
75
+ * (and in the loading skeleton, so it doesn't pop in). This is "leave the
76
+ * scheduler", NOT the details step's back edge — it is suppressed once a
77
+ * slot is chosen so only one Back is ever on screen. Omit it and no back
78
+ * affordance renders at all.
79
+ */
80
+ onBack?: () => void
71
81
  onBooked?: (b: BookingConfirmation) => void
72
82
  className?: string
73
83
  }
74
84
 
75
85
  type Step = 'slot' | 'details' | 'confirmed'
76
86
 
87
+ /** Context-panel geometry — ONE definition for the loaded card and the
88
+ * loading skeleton, which is what keeps the two footprint-identical. */
89
+ const CONTEXT_PANEL_CLASS =
90
+ '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'
91
+
92
+ /**
93
+ * Action panel: the elastic half, scrolling inside the fixed card.
94
+ *
95
+ * Padded only from `lg`. Below it the calendar and times are separate sections
96
+ * with a divider between them, and each pads itself — one padding here would
97
+ * inset that divider from the card's edges instead of letting it run the full
98
+ * width the way every other rule in this card does.
99
+ */
100
+ const ACTION_PANEL_CLASS = 'flex-1 min-w-0 flex flex-col md:min-h-0 lg:p-[var(--spacing-system-l)]'
101
+
102
+ /**
103
+ * The action panel's inset for the steps that are ONE block — details,
104
+ * confirmation, the slot step's error line.
105
+ *
106
+ * The panel itself is padded only from `lg`, because below that the slot step
107
+ * is two sections with a rule between them and each pads itself: one padding
108
+ * on the panel would inset that rule instead of letting it run the card's full
109
+ * width, the way every other rule here does. Every step that is NOT split into
110
+ * sections has to bring the same inset with it — without it the form ran edge
111
+ * to edge on tablet, its inputs touching the card's border.
112
+ *
113
+ * `md:min-h-0 md:overflow-y-auto` comes with it: from `md` up the card states
114
+ * a height, and these steps have no inner scroller of their own the way the
115
+ * times column does, so this is where a long form gets to scroll instead of
116
+ * being cut off at the card's edge.
117
+ */
118
+ const PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-auto lg:p-0'
119
+
120
+ /**
121
+ * The widget's height on desktop: a FIXED 380px, and the ONLY size the whole
122
+ * card states.
123
+ *
124
+ * A floor was not enough. Every stage has a different natural height — the
125
+ * context panel with two hosts ~340, the calendar column ~290, a degraded
126
+ * stage two lines — so `min-height` only pinned the SHORT ones and let the
127
+ * tall one push the wrapper, which is exactly the screen-shake this removes.
128
+ * With a fixed height the box is stated once and nothing inside can move it;
129
+ * anything taller scrolls in place.
130
+ *
131
+ * The elastic half derives from it: the panel passes it to the slot row and
132
+ * the times column scrolls inside what is left. The calendar does not stretch
133
+ * — a date grid has a size, and stretching one is how a month turns into
134
+ * page-tall bands the moment a layout stops pinning a height. It states a
135
+ * WIDTH (`CALENDAR_W`) and its square cells make it as tall as it is wide;
136
+ * that width is chosen to fit inside this number, and the two are checked
137
+ * against each other in one place rather than three.
138
+ *
139
+ * Six week rows, always — `fixedWeeks` — is why a height can be stated at all:
140
+ * a 5-week month and a 6-week month occupy the same box, so paging the
141
+ * calendar cannot resize the card.
142
+ *
143
+ * Exported because the stability has to survive OUTSIDE the card too: a host
144
+ * that swaps the scheduler in and out of a slot (the onboarding "Book a call"
145
+ * promo does exactly that) reserves the same box for whatever it shows
146
+ * instead, so the swap moves nothing below it.
147
+ *
148
+ * Goes on the BORDERED element, always. Under `box-sizing: border-box` the
149
+ * declared height swallows the 1px edges, so a card that carries the border
150
+ * and a host box that carries its own both come out at exactly 380 — put it on
151
+ * an inner wrapper instead and the border lands OUTSIDE the 380, making that
152
+ * card 2px taller than everything it is supposed to match.
153
+ *
154
+ * TWO numbers, because the card has two shapes: 380 as a sidebar beside the
155
+ * calendar (`lg`), 550 as a header strip over it (`md`). Phones get neither —
156
+ * there a card the height of the hand holding it is the right answer and the
157
+ * page is the scroller.
158
+ *
159
+ * A host that reserves this box owes it one thing: content that ADAPTS to the
160
+ * height rather than assuming it. The onboarding promo does it by letting its
161
+ * video take the leftover space at 16:9; a stand-in that just stacks fixed
162
+ * blocks will overflow the shorter of the two.
163
+ */
164
+ export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]'
165
+
166
+ /**
167
+ * The two-panel card's box, shared verbatim by the loading skeleton and the
168
+ * loaded card so neither can drift from the other.
169
+ *
170
+ * Both heights come from {@link MEETING_SCHEDULER_H}; what this adds is the
171
+ * flex column that makes them work. On TABLET the height is stated for the
172
+ * same reason the desktop one is: a CEILING only pins the tall stages. The short ones — the empty
173
+ * month ("No available times in September"), a five-week month, the cold-start
174
+ * skeleton — each settled at their own natural height, so paging a month or
175
+ * waiting out a fetch resized the card under the visitor. Stating the height
176
+ * makes every stage the same box and moves the variation inside, where the
177
+ * times column already scrolls.
178
+ *
179
+ * 550, not the mock's 498: the mock draws one host and no timezone field, and
180
+ * a real link has two hosts and a zone the visitor must be able to change.
181
+ * That is ~55px of content the mock never budgeted for, and the month below it
182
+ * is what would otherwise pay for it.
183
+ *
184
+ * The strip is `shrink-0`, so what is left goes to the slot row, which spends
185
+ * it on a calendar sized to fit (no scroll) and a times list that scrolls on
186
+ * its own.
187
+ *
188
+ * `md:flex md:flex-col` is what makes that possible at all. A stated height on
189
+ * a plain block only clips (this card is `overflow-hidden` for its corners) —
190
+ * it is the flex column plus the inner wrapper's `min-h-0` that lets the
191
+ * content shrink into the height instead of being cut off by it.
192
+ */
193
+ const CARD_CLASS = cn(
194
+ 'rounded-md border border-ods-border bg-ods-card overflow-hidden',
195
+ 'md:flex md:flex-col',
196
+ MEETING_SCHEDULER_H,
197
+ )
198
+
199
+ /** Context strip over action panel, side by side from `lg`. `md:flex-1
200
+ * md:min-h-0` is the pair that makes it exactly as tall as the card states —
201
+ * grow into a stated height that content does not reach, shrink into one it
202
+ * overruns — so neither a short stage nor a long one changes the box. */
203
+ const CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-row'
204
+
205
+ /** The two-line stages (load failure, "booked on HubSpot") — the SAME box as
206
+ * the booking card, because these are stages of one widget in one slot and a
207
+ * stage that collapses to two lines would snap the page shut under the
208
+ * visitor. Their content is centred in it and scrolls if a message ever
209
+ * outgrows it. */
210
+ const CARD_DEGRADED_CLASS = cn(
211
+ 'rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)] flex flex-col items-start gap-[var(--spacing-system-m)]',
212
+ 'md:justify-center md:overflow-y-auto',
213
+ MEETING_SCHEDULER_H,
214
+ )
215
+
77
216
  /**
78
217
  * Fail-closed gate: a link whose declared questions include an unsupported
79
218
  * type, or whose consent block is malformed, must NOT render a half-working
@@ -99,12 +238,14 @@ export function HubSpotMeetingScheduler({
99
238
  hosts,
100
239
  displayTimezone,
101
240
  fallbackUrl,
241
+ onBack,
102
242
  onBooked,
103
243
  className,
104
244
  }: HubSpotMeetingSchedulerProps) {
105
245
  const {
106
246
  availability,
107
247
  isLoadingAvailability,
248
+ isFetchingAvailability,
108
249
  availabilityError,
109
250
  monthOffset,
110
251
  setMonthOffset,
@@ -187,7 +328,7 @@ export function HubSpotMeetingScheduler({
187
328
  if (
188
329
  step === 'slot' &&
189
330
  availability &&
190
- !isLoadingAvailability &&
331
+ !isFetchingAvailability &&
191
332
  durationMs != null &&
192
333
  slots.length === 0 &&
193
334
  availability.hasMore &&
@@ -196,7 +337,14 @@ export function HubSpotMeetingScheduler({
196
337
  autoAdvanceCount.current += 1
197
338
  setMonthOffset(monthOffset + 1)
198
339
  }
199
- }, [step, availability, isLoadingAvailability, durationMs, slots, monthOffset, setMonthOffset])
340
+ }, [step, availability, isFetchingAvailability, durationMs, slots, monthOffset, setMonthOffset])
341
+
342
+ /** The back edge's destination from the form: the calendar, with any
343
+ * submit error cleared so the visitor doesn't carry it back. */
344
+ const backToSlot = useCallback(() => {
345
+ setStep('slot')
346
+ setBookingError(null)
347
+ }, [])
200
348
 
201
349
  const handleSubmit = useCallback(
202
350
  async (payload: Record<string, unknown>) => {
@@ -231,18 +379,16 @@ export function HubSpotMeetingScheduler({
231
379
  // ---- terminal / degraded states -----------------------------------------
232
380
 
233
381
  if (isLoadingAvailability && !availability) {
234
- // Per-region skeletons, composed inside the SAME card wrappers as the
235
- // loaded state: `ContextPanelSkeleton` (profile side), `CalendarSkeleton`
236
- // (day buttons), `TimeChipsSkeleton` (hour options). Identical footprints
237
- // by construction loading ⇄ loaded never shifts layout.
382
+ // COLD start only a month already in the query cache renders straight
383
+ // into the loaded card. Composed inside the SAME wrappers as that card
384
+ // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing
385
+ // shifts when it swaps.
238
386
  return (
239
- <div className={cn('rounded-md border border-ods-border bg-ods-card overflow-hidden', className)}>
240
- <div className="flex flex-col md:flex-row">
241
- <ContextPanelSkeleton className="p-[var(--spacing-system-lf)] md:w-80 md:shrink-0 border-b md:border-b-0 md:border-r border-ods-border" />
242
- <div className="flex-1 min-w-0 p-[var(--spacing-system-lf)] md:min-h-[32rem] flex flex-col gap-[var(--spacing-system-md)]">
243
- {/* Static heading renders REAL — it needs no data. */}
244
- <p className="text-h5 text-ods-text-secondary">Select a date &amp; time</p>
245
- <SlotPickerSkeleton />
387
+ <div className={cn(CARD_CLASS, className)}>
388
+ <div className={CARD_INNER_CLASS}>
389
+ <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />
390
+ <div className={ACTION_PANEL_CLASS}>
391
+ <SlotPickerSkeleton monthOffset={monthOffset} />
246
392
  </div>
247
393
  </div>
248
394
  </div>
@@ -252,10 +398,7 @@ export function HubSpotMeetingScheduler({
252
398
  if (availabilityError || !availability) {
253
399
  return (
254
400
  <div
255
- className={cn(
256
- 'rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)] flex flex-col items-start gap-[var(--spacing-system-md)]',
257
- className,
258
- )}
401
+ className={cn(CARD_DEGRADED_CLASS, className)}
259
402
  >
260
403
  <p className="text-h6 text-ods-text-secondary">
261
404
  We couldn&apos;t load available call times. Please try again shortly.
@@ -270,10 +413,7 @@ export function HubSpotMeetingScheduler({
270
413
  // questions or consent we can't faithfully reproduce.
271
414
  return (
272
415
  <div
273
- className={cn(
274
- 'rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)] flex flex-col items-start gap-[var(--spacing-system-md)]',
275
- className,
276
- )}
416
+ className={cn(CARD_DEGRADED_CLASS, className)}
277
417
  >
278
418
  <p className="text-h6 text-ods-text-secondary">This meeting type is booked directly on HubSpot.</p>
279
419
  {escapeHatch}
@@ -284,8 +424,8 @@ export function HubSpotMeetingScheduler({
284
424
  // ---- the card ------------------------------------------------------------
285
425
 
286
426
  return (
287
- <div className={cn('rounded-md border border-ods-border bg-ods-card overflow-hidden', className)}>
288
- <div className="flex flex-col md:flex-row">
427
+ <div className={cn(CARD_CLASS, className)}>
428
+ <div className={CARD_INNER_CLASS}>
289
429
  <SchedulerContextPanel
290
430
  hosts={shownHosts}
291
431
  title={title}
@@ -300,29 +440,35 @@ export function HubSpotMeetingScheduler({
300
440
  }}
301
441
  timezone={timezone}
302
442
  onTimezoneChange={setTimezone}
443
+ // ONE back edge for the whole card, in the panel that never swaps.
444
+ // Its destination is simply "the previous step": from the form back
445
+ // to the calendar, from the calendar out of the scheduler (if the
446
+ // host gave us an exit at all). Two Backs on screen reading the same
447
+ // word with different destinations was the ambiguity the details
448
+ // step used to carry.
449
+ onBack={step === 'details' ? backToSlot : onBack}
303
450
  locked={step !== 'slot'}
304
- className="p-[var(--spacing-system-lf)] md:w-80 md:shrink-0 border-b md:border-b-0 md:border-r border-ods-border"
451
+ // The zone still governs every time on screen — including the
452
+ // summary line on the form — so the picker stays until there is
453
+ // nothing left to re-read in it.
454
+ showTimezone={step !== 'confirmed'}
455
+ className={CONTEXT_PANEL_CLASS}
305
456
  />
306
457
 
307
- {/* min-height pins the card so slot details confirmed transitions
308
- never jump the page (sized to the slot step, the tallest one);
309
- flex column so shorter steps can center vertically. */}
310
- <div className="flex-1 min-w-0 p-[var(--spacing-system-lf)] md:min-h-[32rem] flex flex-col">
458
+ {/* Scrolls INSIDE the fixed card rather than growing it see
459
+ MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's
460
+ default `min-height:auto` refuses to shrink below its content. */}
461
+ <div className={ACTION_PANEL_CLASS}>
311
462
  {step === 'confirmed' && confirmation && timezone ? (
312
- <Confirmation confirmation={confirmation} timezone={timezone} />
463
+ <div className={cn('flex flex-1 flex-col', PANEL_STEP_CLASS)}>
464
+ <Confirmation confirmation={confirmation} timezone={timezone} />
465
+ </div>
313
466
  ) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
314
- <div className="flex flex-1 flex-col gap-[var(--spacing-system-md)]">
315
- {/* App-standard BackButton pinned at the TOP of the panel,
316
- exactly where the slot step's "Select a date & time" header
317
- sits; the form centers in the remaining space. */}
318
- <BackButton
319
- label="Back"
320
- onClick={() => {
321
- setStep('slot')
322
- setBookingError(null)
323
- }}
324
- />
325
- <div className="flex flex-1 flex-col justify-center gap-[var(--spacing-system-md)]">
467
+ <div className={cn('flex flex-1 flex-col gap-[var(--spacing-system-m)]', PANEL_STEP_CLASS)}>
468
+ {/* Top-aligned, and no back edge of its own: the ONE back edge
469
+ lives in the context panel at every step (see `contextBack`),
470
+ which is where the design puts it and the only spot that
471
+ stays put while this side swaps between calendar and form. */}
326
472
  <p className="text-h4 text-ods-text-primary">
327
473
  {new Intl.DateTimeFormat(undefined, {
328
474
  timeZone: timezone,
@@ -360,50 +506,50 @@ export function HubSpotMeetingScheduler({
360
506
  honeypotInputProps={honeypotInputProps}
361
507
  getSignals={getSignals}
362
508
  />
363
- </div>
364
509
  </div>
365
510
  ) : (
366
- <div className="flex flex-col gap-[var(--spacing-system-md)]">
367
- <p className="text-h5 text-ods-text-secondary">Select a date &amp; time</p>
511
+ <div className="flex flex-col gap-[var(--spacing-system-m)] md:min-h-0 md:flex-1">
368
512
  {bookingError === 'SLOT_TAKEN' && (
369
- <p className="text-h6 text-ods-error">
513
+ <p className="px-[var(--spacing-system-l)] pt-[var(--spacing-system-l)] text-h6 text-ods-error lg:p-0">
370
514
  That time is no longer available — if you just submitted, check your email for a confirmation
371
515
  before rebooking.
372
516
  </p>
373
517
  )}
374
518
  {timezone && durationMs != null ? (
375
- slots.length > 0 || isLoadingAvailability ? (
376
- <SlotPicker
377
- slots={slots}
378
- timezone={timezone}
379
- monthOffset={monthOffset}
380
- onMonthOffsetChange={(o) => {
381
- setSelectedDay(null)
382
- setSelectedSlot(null)
383
- setMonthOffset(o)
384
- }}
385
- selectedSlot={selectedSlot}
386
- onSelectSlot={(ms) => {
387
- setSelectedSlot(ms)
388
- setStep('details')
389
- }}
390
- selectedDay={selectedDay}
391
- onSelectDay={setSelectedDay}
392
- isLoading={isLoadingAvailability}
393
- />
394
- ) : (
395
- <div className="flex flex-col items-start gap-[var(--spacing-system-md)]">
396
- <p className="text-h6 text-ods-text-secondary">
397
- {isLoadingAvailability ? 'Checking more dates…' : 'No call times are published right now.'}
398
- </p>
399
- {!isLoadingAvailability && escapeHatch}
400
- </div>
401
- )
519
+ // The picker owns BOTH the loading and the empty month now —
520
+ // it keeps the calendar up either way, so there is no branch
521
+ // here that can take it off screen mid-flow.
522
+ <SlotPicker
523
+ slots={slots}
524
+ timezone={timezone}
525
+ monthOffset={monthOffset}
526
+ onMonthOffsetChange={(o) => {
527
+ setSelectedDay(null)
528
+ setSelectedSlot(null)
529
+ setMonthOffset(o)
530
+ }}
531
+ selectedSlot={selectedSlot}
532
+ onSelectSlot={(ms) => {
533
+ setSelectedSlot(ms)
534
+ setStep('details')
535
+ }}
536
+ selectedDay={selectedDay}
537
+ onSelectDay={setSelectedDay}
538
+ isLoading={isLoadingAvailability}
539
+ />
540
+ ) : durations.length === 0 && !isFetchingAvailability ? (
541
+ // The LINK publishes nothing at all a different thing from a
542
+ // fully-booked month, and the only case the escape hatch is
543
+ // the right answer to.
544
+ <div className="flex flex-col items-start gap-[var(--spacing-system-m)]">
545
+ <p className="text-h6 text-ods-text-secondary">No call times are published right now.</p>
546
+ {escapeHatch}
547
+ </div>
402
548
  ) : (
403
549
  // Pre-mount timezone-resolution window (every SSR load hits
404
- // this for one frame-batch) — SAME per-region skeletons, no
405
- // bare rectangle, no shift when the real picker swaps in.
406
- <SlotPickerSkeleton />
550
+ // this for one frame-batch) — same layout, real calendar, so
551
+ // nothing shifts when the picker takes over.
552
+ <SlotPickerSkeleton monthOffset={monthOffset} />
407
553
  )}
408
554
  </div>
409
555
  )}