@flamingo-stack/openframe-frontend-core 0.0.594 → 0.0.595-snapshot.20260905173123

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 (161) hide show
  1. package/dist/{chunk-HQWZ4IXU.cjs → chunk-3CH6VRJX.cjs} +24 -24
  2. package/dist/{chunk-HQWZ4IXU.cjs.map → chunk-3CH6VRJX.cjs.map} +1 -1
  3. package/dist/{chunk-LKN2KKYZ.js → chunk-4K7TCJ35.js} +6 -59
  4. package/dist/{chunk-LKN2KKYZ.js.map → chunk-4K7TCJ35.js.map} +1 -1
  5. package/dist/{chunk-YLVPQNLO.cjs → chunk-5MSHWXJ5.cjs} +170 -223
  6. package/dist/chunk-5MSHWXJ5.cjs.map +1 -0
  7. package/dist/{chunk-CCLPJSY3.cjs → chunk-73BECFSU.cjs} +15 -15
  8. package/dist/{chunk-CCLPJSY3.cjs.map → chunk-73BECFSU.cjs.map} +1 -1
  9. package/dist/{chunk-F5Q3DTP5.js → chunk-7EUE3YTC.js} +8 -8
  10. package/dist/{chunk-GBIC3VNA.js → chunk-7KGN7DV6.js} +6 -6
  11. package/dist/{chunk-YZLUOZKR.js → chunk-7Q66A4CF.js} +2 -2
  12. package/dist/{chunk-DRZ224RU.js → chunk-ANSZVKI2.js} +8 -8
  13. package/dist/{chunk-N3RHX26N.js → chunk-AZZVSEP5.js} +3 -3
  14. package/dist/{chunk-66I2QUFE.cjs → chunk-B26UT36N.cjs} +6 -6
  15. package/dist/{chunk-66I2QUFE.cjs.map → chunk-B26UT36N.cjs.map} +1 -1
  16. package/dist/{chunk-UBRVIVY4.cjs → chunk-B2CSYMKW.cjs} +298 -285
  17. package/dist/chunk-B2CSYMKW.cjs.map +1 -0
  18. package/dist/{chunk-ZK3AZ5YF.cjs → chunk-BTOL222V.cjs} +11 -11
  19. package/dist/{chunk-ZK3AZ5YF.cjs.map → chunk-BTOL222V.cjs.map} +1 -1
  20. package/dist/{chunk-S7VW4HVL.cjs → chunk-DODE3PD4.cjs} +49 -49
  21. package/dist/{chunk-S7VW4HVL.cjs.map → chunk-DODE3PD4.cjs.map} +1 -1
  22. package/dist/{chunk-ZZHHKE2Q.js → chunk-ECXQX4EP.js} +2 -2
  23. package/dist/{chunk-NEFNARFF.cjs → chunk-EEV7SSBJ.cjs} +30 -30
  24. package/dist/{chunk-NEFNARFF.cjs.map → chunk-EEV7SSBJ.cjs.map} +1 -1
  25. package/dist/{chunk-3OQIZPUX.js → chunk-EO3NEGWB.js} +4 -4
  26. package/dist/{chunk-MOHSA65M.js → chunk-EZM5DNII.js} +2 -2
  27. package/dist/{chunk-IKQVXZB5.js → chunk-GSPS6Y5U.js} +6 -6
  28. package/dist/{chunk-CQ35OMPJ.cjs → chunk-I2BNLWC3.cjs} +3 -3
  29. package/dist/{chunk-CQ35OMPJ.cjs.map → chunk-I2BNLWC3.cjs.map} +1 -1
  30. package/dist/{chunk-HOLK4ZMT.js → chunk-II2GBWNW.js} +4 -4
  31. package/dist/{chunk-PETYVGEQ.cjs → chunk-JKGVXLTT.cjs} +5 -5
  32. package/dist/{chunk-PETYVGEQ.cjs.map → chunk-JKGVXLTT.cjs.map} +1 -1
  33. package/dist/{chunk-5EM6NGBK.cjs → chunk-KOQCA5X5.cjs} +17 -27
  34. package/dist/chunk-KOQCA5X5.cjs.map +1 -0
  35. package/dist/{chunk-ACU7J5BL.cjs → chunk-KPE7OQF6.cjs} +84 -84
  36. package/dist/{chunk-ACU7J5BL.cjs.map → chunk-KPE7OQF6.cjs.map} +1 -1
  37. package/dist/{chunk-C4YJC5X7.cjs → chunk-KRKW63LG.cjs} +9 -9
  38. package/dist/{chunk-C4YJC5X7.cjs.map → chunk-KRKW63LG.cjs.map} +1 -1
  39. package/dist/{chunk-ZEG3ZJPY.cjs → chunk-LE5SZPMN.cjs} +33 -33
  40. package/dist/{chunk-ZEG3ZJPY.cjs.map → chunk-LE5SZPMN.cjs.map} +1 -1
  41. package/dist/{chunk-A5AKGC4S.js → chunk-LPMDCQLL.js} +17 -27
  42. package/dist/chunk-LPMDCQLL.js.map +1 -0
  43. package/dist/{chunk-EWOFXJGH.cjs → chunk-M3OQLL7D.cjs} +63 -63
  44. package/dist/{chunk-EWOFXJGH.cjs.map → chunk-M3OQLL7D.cjs.map} +1 -1
  45. package/dist/{chunk-FKXDDJEQ.cjs → chunk-M4YQUHOR.cjs} +5 -5
  46. package/dist/{chunk-FKXDDJEQ.cjs.map → chunk-M4YQUHOR.cjs.map} +1 -1
  47. package/dist/{chunk-5XQMEDGE.cjs → chunk-OHJ4QOWN.cjs} +12 -12
  48. package/dist/{chunk-5XQMEDGE.cjs.map → chunk-OHJ4QOWN.cjs.map} +1 -1
  49. package/dist/{chunk-Y6I2DGKC.cjs → chunk-OMKI5T2G.cjs} +1 -1
  50. package/dist/chunk-OMKI5T2G.cjs.map +1 -0
  51. package/dist/{chunk-M5HGULIY.js → chunk-OQODJ26H.js} +5 -5
  52. package/dist/{chunk-MEWAFY6X.js → chunk-OT5IQMSI.js} +4 -4
  53. package/dist/{chunk-Y22VQDVW.js → chunk-QB244ZDI.js} +4 -4
  54. package/dist/{chunk-3T7ZP3TL.js → chunk-QL6ECU6Y.js} +3 -3
  55. package/dist/{chunk-3XDPXOMS.js → chunk-R2SZYRB2.js} +7 -7
  56. package/dist/{chunk-QAVT6P5F.cjs → chunk-RFA5T5MW.cjs} +28 -28
  57. package/dist/{chunk-QAVT6P5F.cjs.map → chunk-RFA5T5MW.cjs.map} +1 -1
  58. package/dist/{chunk-BTF3CNDP.js → chunk-ROQO355A.js} +1 -1
  59. package/dist/chunk-ROQO355A.js.map +1 -0
  60. package/dist/{chunk-43ADGEWL.cjs → chunk-UJAMXLA3.cjs} +40 -40
  61. package/dist/{chunk-43ADGEWL.cjs.map → chunk-UJAMXLA3.cjs.map} +1 -1
  62. package/dist/{chunk-KVNA3HU2.js → chunk-XANQTIHS.js} +3 -3
  63. package/dist/{chunk-PMN7WDXI.js → chunk-YUHOKPKS.js} +26 -13
  64. package/dist/chunk-YUHOKPKS.js.map +1 -0
  65. package/dist/components/case-studies/index.cjs +16 -16
  66. package/dist/components/case-studies/index.js +6 -6
  67. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +4 -4
  69. package/dist/components/chat/index.js +3 -3
  70. package/dist/components/contact/index.cjs +7 -7
  71. package/dist/components/contact/index.js +6 -6
  72. package/dist/components/docs/index.cjs +10 -10
  73. package/dist/components/docs/index.js +9 -9
  74. package/dist/components/embeds/index.cjs +7 -7
  75. package/dist/components/embeds/index.js +6 -6
  76. package/dist/components/faq/index.cjs +7 -7
  77. package/dist/components/faq/index.js +6 -6
  78. package/dist/components/features/index.cjs +6 -6
  79. package/dist/components/features/index.js +5 -5
  80. package/dist/components/help-center-pages/index.cjs +26 -26
  81. package/dist/components/help-center-pages/index.js +17 -17
  82. package/dist/components/icons/index.cjs +2 -2
  83. package/dist/components/icons/index.js +1 -1
  84. package/dist/components/icons/mlg-logo.d.ts.map +1 -1
  85. package/dist/components/index.cjs +56 -56
  86. package/dist/components/index.js +17 -17
  87. package/dist/components/meeting-scheduler/booking-form.d.ts +70 -6
  88. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  89. package/dist/components/meeting-scheduler/context-panel.d.ts +9 -1
  90. package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
  91. package/dist/components/meeting-scheduler/index.cjs +395 -188
  92. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  93. package/dist/components/meeting-scheduler/index.d.ts +55 -1
  94. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  95. package/dist/components/meeting-scheduler/index.js +381 -174
  96. package/dist/components/meeting-scheduler/index.js.map +1 -1
  97. package/dist/components/meeting-scheduler/slot-picker.d.ts +8 -1
  98. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  99. package/dist/components/navigation/index.cjs +9 -9
  100. package/dist/components/navigation/index.js +8 -8
  101. package/dist/components/onboarding-guides/index.cjs +9 -9
  102. package/dist/components/onboarding-guides/index.js +8 -8
  103. package/dist/components/related-content/index.cjs +3 -3
  104. package/dist/components/related-content/index.js +2 -2
  105. package/dist/components/tickets/index.cjs +10 -10
  106. package/dist/components/tickets/index.js +9 -9
  107. package/dist/components/ui/benefit-card.d.ts.map +1 -1
  108. package/dist/components/ui/index.cjs +6 -6
  109. package/dist/components/ui/index.js +5 -5
  110. package/dist/hooks/index.cjs +3 -3
  111. package/dist/hooks/index.js +2 -2
  112. package/dist/index.cjs +10 -10
  113. package/dist/index.js +9 -9
  114. package/dist/schemas/meeting-booking-schema.cjs +19 -4
  115. package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
  116. package/dist/schemas/meeting-booking-schema.d.ts +41 -16
  117. package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
  118. package/dist/schemas/meeting-booking-schema.js +19 -5
  119. package/dist/schemas/meeting-booking-schema.js.map +1 -1
  120. package/dist/types/index.cjs.map +1 -1
  121. package/dist/types/index.js.map +1 -1
  122. package/dist/utils/index.cjs +18 -40
  123. package/dist/utils/index.cjs.map +1 -1
  124. package/dist/utils/index.js +18 -40
  125. package/dist/utils/index.js.map +1 -1
  126. package/dist/utils/source-icons.d.ts.map +1 -1
  127. package/package.json +1 -1
  128. package/src/components/chat/entity-cards/dispatch.tsx +0 -57
  129. package/src/components/icons/mlg-logo.tsx +1 -7
  130. package/src/components/meeting-scheduler/booking-form.tsx +425 -154
  131. package/src/components/meeting-scheduler/context-panel.tsx +13 -3
  132. package/src/components/meeting-scheduler/index.tsx +317 -69
  133. package/src/components/meeting-scheduler/slot-picker.tsx +17 -1
  134. package/src/components/ui/benefit-card.tsx +30 -9
  135. package/src/schemas/meeting-booking-schema.ts +58 -4
  136. package/src/types/tmcg.ts +0 -2
  137. package/src/utils/format.ts +1 -1
  138. package/src/utils/source-icons.ts +0 -24
  139. package/dist/chunk-5EM6NGBK.cjs.map +0 -1
  140. package/dist/chunk-A5AKGC4S.js.map +0 -1
  141. package/dist/chunk-BTF3CNDP.js.map +0 -1
  142. package/dist/chunk-PMN7WDXI.js.map +0 -1
  143. package/dist/chunk-UBRVIVY4.cjs.map +0 -1
  144. package/dist/chunk-Y6I2DGKC.cjs.map +0 -1
  145. package/dist/chunk-YLVPQNLO.cjs.map +0 -1
  146. /package/dist/{chunk-F5Q3DTP5.js.map → chunk-7EUE3YTC.js.map} +0 -0
  147. /package/dist/{chunk-GBIC3VNA.js.map → chunk-7KGN7DV6.js.map} +0 -0
  148. /package/dist/{chunk-YZLUOZKR.js.map → chunk-7Q66A4CF.js.map} +0 -0
  149. /package/dist/{chunk-DRZ224RU.js.map → chunk-ANSZVKI2.js.map} +0 -0
  150. /package/dist/{chunk-N3RHX26N.js.map → chunk-AZZVSEP5.js.map} +0 -0
  151. /package/dist/{chunk-ZZHHKE2Q.js.map → chunk-ECXQX4EP.js.map} +0 -0
  152. /package/dist/{chunk-3OQIZPUX.js.map → chunk-EO3NEGWB.js.map} +0 -0
  153. /package/dist/{chunk-MOHSA65M.js.map → chunk-EZM5DNII.js.map} +0 -0
  154. /package/dist/{chunk-IKQVXZB5.js.map → chunk-GSPS6Y5U.js.map} +0 -0
  155. /package/dist/{chunk-HOLK4ZMT.js.map → chunk-II2GBWNW.js.map} +0 -0
  156. /package/dist/{chunk-M5HGULIY.js.map → chunk-OQODJ26H.js.map} +0 -0
  157. /package/dist/{chunk-MEWAFY6X.js.map → chunk-OT5IQMSI.js.map} +0 -0
  158. /package/dist/{chunk-Y22VQDVW.js.map → chunk-QB244ZDI.js.map} +0 -0
  159. /package/dist/{chunk-3T7ZP3TL.js.map → chunk-QL6ECU6Y.js.map} +0 -0
  160. /package/dist/{chunk-3XDPXOMS.js.map → chunk-R2SZYRB2.js.map} +0 -0
  161. /package/dist/{chunk-KVNA3HU2.js.map → chunk-XANQTIHS.js.map} +0 -0
@@ -47,6 +47,14 @@ export interface SchedulerContextPanelProps {
47
47
  * states the length and a live selector beside a filled-in form invites a
48
48
  * change that would throw the form away. Going Back restores them. */
49
49
  locked?: boolean;
50
+ /**
51
+ * The link's length is not the visitor's to choose here: the static duration
52
+ * line renders and the chips never do, at every step. `flow="details-first"`
53
+ * sets it — the mock draws "30 min call" beside the host on the calendar step
54
+ * and offers no selector — and unlike `locked` it has NO effect on the
55
+ * timezone picker, which on that step is the only control the visitor has.
56
+ */
57
+ fixedDuration?: boolean;
50
58
  /** Whether the timezone picker renders. Default true: the zone governs every
51
59
  * time on screen, the form's summary line included, so it stays reachable
52
60
  * right up to the confirmation — where there is nothing left to re-read. */
@@ -165,6 +173,7 @@ export function SchedulerContextPanel({
165
173
  onBack,
166
174
  backLabel = 'Back',
167
175
  locked = false,
176
+ fixedDuration = false,
168
177
  showTimezone = true,
169
178
  className,
170
179
  }: SchedulerContextPanelProps) {
@@ -223,8 +232,9 @@ export function SchedulerContextPanel({
223
232
  </div>
224
233
  )}
225
234
 
226
- {locked || durationsMs.length <= 1
227
- ? // Post-selection, or a link that offers one length: a static line,
235
+ {locked || fixedDuration || durationsMs.length <= 1
236
+ ? // Post-selection, a fixed-length flow, or a link that offers one
237
+ // length: a static line,
228
238
  // which is what sits beside the host on the header-strip layouts.
229
239
  selectedDurationMs != null && (
230
240
  <p className="shrink-0 text-ods-text-secondary text-h6">
@@ -239,7 +249,7 @@ export function SchedulerContextPanel({
239
249
 
240
250
  {/* Several lengths on offer: chips, which need a full row of their
241
251
  own at every width — so they stay OUT of the identity row. */}
242
- {!locked && durationsMs.length > 1 && (
252
+ {!locked && !fixedDuration && durationsMs.length > 1 && (
243
253
  <div className="flex flex-col gap-[var(--spacing-system-xxs)]">
244
254
  <p className={FIELD_LABEL_CLASS}>Duration</p>
245
255
  <div className="flex flex-wrap gap-[var(--spacing-system-xs)]">
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
39
+ import type { ComponentType } from 'react';
39
40
  import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
40
41
  import { useHumanitySignals } from '../../hooks/use-humanity-signals';
41
42
  import { useMeetingBooking } from '../../hooks/use-meeting-booking';
@@ -50,7 +51,19 @@ import {
50
51
  import { cn } from '../../utils/cn';
51
52
  import { formatDurationCompact } from '../../utils/format';
52
53
  import { Alert, AlertDescription, Button } from '../ui';
53
- import { BookingForm } from './booking-form';
54
+ import { BookingForm, BookingFormSkeleton, type BookingFormProps } from './booking-form';
55
+
56
+ // The override surface: a host builds its `detailsForm` by re-rendering
57
+ // `BookingForm` with `fieldRows`, so it needs both from this entry point (the
58
+ // package exports `./components/meeting-scheduler`, not the file beneath it).
59
+ export {
60
+ BookingForm,
61
+ BookingFormSkeleton,
62
+ type BookingFormProps,
63
+ type BookingFieldRow,
64
+ type BookingFieldSlot,
65
+ type BookingFormConsent,
66
+ } from './booking-form';
54
67
  import { Confirmation } from './confirmation';
55
68
  import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
56
69
  import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
@@ -101,10 +114,48 @@ export interface HubSpotMeetingSchedulerProps {
101
114
  onBack?: () => void;
102
115
  onBooked?: (b: BookingConfirmation) => void;
103
116
  className?: string;
117
+ /**
118
+ * Panel order.
119
+ *
120
+ * `'slot-first'` (default, unchanged): calendar → details → confirmed.
121
+ * `'details-first'`: the form comes FIRST and the slot click submits the
122
+ * already-validated payload. Campaign landing pages use it, where "tell us
123
+ * about your setup, then pick a time" is the conversion shape.
124
+ *
125
+ * This is not a cosmetic swap — see the flow-dependent branches below. It is
126
+ * opt-in precisely so `slot-first` stays byte-identical.
127
+ */
128
+ flow?: 'slot-first' | 'details-first';
129
+ /**
130
+ * Replace the details panel — step ONE under `flow="details-first"`, step two
131
+ * otherwise — with a host-supplied form. It receives exactly what the built-in
132
+ * one does, so the injected form keeps the widget's contract: the deferred
133
+ * schema, the honeypot and elapsed-ms signals, the verbatim consent block, the
134
+ * `onSubmit` handoff and the `isSubmitting` lock.
135
+ *
136
+ * The intended shape is a THIN wrapper that re-renders `BookingForm` with
137
+ * `fieldRows` (a layout re-arrangement, all machinery reused), not a
138
+ * hand-written form. Anything reimplementing the contract loses the bot
139
+ * protection and the consent guarantees.
140
+ *
141
+ * A COMPONENT type, not a render callback: React then owns its identity, so
142
+ * it reconciles across steps and keeps its own state instead of remounting —
143
+ * and the honeypot `ref` rides through as a normal prop.
144
+ *
145
+ * Defaults to the built-in form, so every existing embed is untouched.
146
+ */
147
+ detailsForm?: ComponentType<BookingFormProps>;
104
148
  }
105
149
 
106
150
  type Step = 'slot' | 'details' | 'confirmed';
107
151
 
152
+ /** Values collected at the details step, held until a slot picks the instant. */
153
+ type StashedDetails = {
154
+ payload: Record<string, unknown>;
155
+ /** Humanity signals captured while the form (and its honeypot) was mounted. */
156
+ signals: Record<string, string | number>;
157
+ };
158
+
108
159
  /** Context-panel geometry — ONE definition for the loaded card and the
109
160
  * loading skeleton, which is what keeps the two footprint-identical. */
110
161
  const CONTEXT_PANEL_CLASS =
@@ -184,6 +235,27 @@ const PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-a
184
235
  */
185
236
  export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
186
237
 
238
+ /**
239
+ * The same box for `flow="details-first"`, where the tallest stage is the
240
+ * DETAILS FORM (email + first/last + the link's declared questions + the
241
+ * consent block + Continue) rather than the calendar.
242
+ *
243
+ * Still a fixed pair, not a floor: everything below the card derives a definite
244
+ * height from it, and a floor was already tried here — it pinned the short
245
+ * stages and let the tall one push the wrapper, which is the screen-shake the
246
+ * fixed height exists to remove.
247
+ *
248
+ * ONE number from `md` up, not two: 638px, the card both mocks draw
249
+ * (`4904:117130` form-only, `4904:118213` three columns). The form stage has no
250
+ * sidebar to stack into a header strip, so nothing about it changes between
251
+ * tablet and desktop, and the calendar stage fits the same box at both — the
252
+ * times column scrolls inside it as it always has.
253
+ *
254
+ * A host reserving the box for a details-first card reserves THIS, not the
255
+ * constant above.
256
+ */
257
+ export const MEETING_SCHEDULER_DETAILS_FIRST_H = 'md:h-[39.875rem]';
258
+
187
259
  /**
188
260
  * The two-panel card's box, shared verbatim by the loading skeleton and the
189
261
  * loaded card so neither can drift from the other.
@@ -214,7 +286,9 @@ export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
214
286
  const CARD_CLASS = cn(
215
287
  'overflow-hidden rounded-md border border-ods-border bg-ods-card',
216
288
  'md:flex md:flex-col',
217
- MEETING_SCHEDULER_H,
289
+ // The height is NOT baked in any more: it is chosen per flow at each render
290
+ // site (`flowHeight`), because details-first needs a taller box and both
291
+ // constants are module scope.
218
292
  );
219
293
 
220
294
  /** Context strip over action panel, side by side from `lg`. `md:flex-1
@@ -231,7 +305,6 @@ const CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-r
231
305
  const CARD_DEGRADED_CLASS = cn(
232
306
  'flex flex-col items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]',
233
307
  'md:justify-center md:overflow-y-auto',
234
- MEETING_SCHEDULER_H,
235
308
  );
236
309
 
237
310
  /**
@@ -262,6 +335,8 @@ export function HubSpotMeetingScheduler({
262
335
  onBack,
263
336
  onBooked,
264
337
  className,
338
+ flow = 'slot-first',
339
+ detailsForm: DetailsForm = BookingForm,
265
340
  }: HubSpotMeetingSchedulerProps) {
266
341
  const {
267
342
  availability,
@@ -275,6 +350,8 @@ export function HubSpotMeetingScheduler({
275
350
  isSubmitting,
276
351
  } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });
277
352
 
353
+ const detailsFirst = flow === 'details-first';
354
+
278
355
  // Zone resolution happens POST-mount (Intl) unless the embedder pins one —
279
356
  // the initial (server) render stays deterministic; time labels appear once
280
357
  // a zone exists.
@@ -297,7 +374,20 @@ export function HubSpotMeetingScheduler({
297
374
  }, [hydrated]);
298
375
  const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;
299
376
 
300
- const [step, setStep] = useState<Step>('slot');
377
+ const [step, setStep] = useState<Step>(detailsFirst ? 'details' : 'slot');
378
+ // State, not a ref: the link-swap reset below writes it DURING RENDER, and
379
+ // this file's own rule forbids writing a ref there. "Frozen" means written
380
+ // once at Continue, not `useRef`.
381
+ const [stash, setStash] = useState<StashedDetails | null>(null);
382
+
383
+ /**
384
+ * details-first step ONE is the form and nothing else — the mock
385
+ * (`4904:117130`) draws no context panel beside it, and paints the card on the
386
+ * page ground so the fields read as the raised elements. The panel (host,
387
+ * fixed length, timezone, and the Back edge to this form) belongs to the
388
+ * calendar step, exactly as slot-first has always drawn it.
389
+ */
390
+ const formOnly = detailsFirst && step === 'details';
301
391
  const [pickedDurationMs, setDurationMs] = useState<number | null>(null);
302
392
  const [pickedDay, setSelectedDay] = useState<string | null>(null);
303
393
  const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
@@ -316,12 +406,16 @@ export function HubSpotMeetingScheduler({
316
406
  const [machineFor, setMachineFor] = useState(meetingId);
317
407
  if (machineFor !== meetingId) {
318
408
  setMachineFor(meetingId);
319
- setStep('slot');
409
+ setStep(detailsFirst ? 'details' : 'slot');
320
410
  setDurationMs(null);
321
411
  setSelectedDay(null);
322
412
  setSelectedSlot(null);
323
413
  setConfirmation(null);
324
414
  setBookingError(null);
415
+ // Clearing the stash is load-bearing: otherwise the swapped-in link would
416
+ // POST the PREVIOUS link's answers, against a schema the server rebuilds
417
+ // from the new link's metadata.
418
+ setStash(null);
325
419
  }
326
420
  useEffect(() => {
327
421
  autoAdvanceCount.current = 0;
@@ -396,10 +490,53 @@ export function HubSpotMeetingScheduler({
396
490
  setBookingError(null);
397
491
  }, []);
398
492
 
493
+ /** details-first's inverse: back from the CALENDAR to the form. The stash is
494
+ * what repopulates it, so nothing is cleared here but the error. */
495
+ const backToDetails = useCallback(() => {
496
+ setStep('details');
497
+ setBookingError(null);
498
+ }, []);
499
+
500
+ /**
501
+ * details-first: Continue does not POST. It validates, freezes the answers
502
+ * and the humanity signals (captured HERE, while the form and its honeypot
503
+ * are still mounted — `getSignals()` reads a detached ref once they unmount),
504
+ * and hands over to the calendar.
505
+ */
506
+ const stashDetails = useCallback(
507
+ (payload: Record<string, unknown>): Promise<void> => {
508
+ setBookingError(null);
509
+ setStash({ payload, signals: getSignals() });
510
+ setStep('slot');
511
+ // The visitor has been filling a form; the month they are about to see
512
+ // may have moved on, and its hop budget should start fresh.
513
+ autoAdvanceCount.current = 0;
514
+ void refetchAvailability();
515
+ // Deliberately NOT `async`: nothing here is awaited. The refetch is
516
+ // fire-and-forget so the calendar paints from cache the moment the step
517
+ // flips, and awaiting it would hold `BookingForm`'s isSubmitting past
518
+ // the unmount. `onSubmit` is Promise-returning, hence the explicit
519
+ // resolve rather than an empty async body (which `require-await` flags).
520
+ return Promise.resolve();
521
+ },
522
+ [getSignals, refetchAvailability],
523
+ );
524
+
399
525
  const handleSubmit = useCallback(
400
526
  async (payload: Record<string, unknown>) => {
527
+ // Captured BEFORE the clear below: `book()` reports a re-entrant call as
528
+ // a VALIDATION code carrying the message 'Already submitting', and by
529
+ // then this clear has already run — so a double-click would wipe a
530
+ // visible SLOT_TAKEN alert on its way to being ignored.
531
+ const priorError = bookingError;
401
532
  setBookingError(null);
402
533
  const result = await book(payload);
534
+
535
+ // Not an error: the hook's in-flight guard. No toast, no step change.
536
+ if (!result.ok && result.message === 'Already submitting') {
537
+ setBookingError(priorError);
538
+ return;
539
+ }
403
540
  if (result.ok && result.confirmation) {
404
541
  setConfirmation(result.confirmation);
405
542
  setStep('confirmed');
@@ -413,8 +550,17 @@ export function HubSpotMeetingScheduler({
413
550
  // signal so the retry isn't flagged too-fast.
414
551
  setSelectedSlot(null);
415
552
  setStep('slot');
416
- resetSignals();
553
+ // NOT in details-first: the retry re-POSTs the frozen stash, so
554
+ // `getSignals()` is never consulted again — but `resetSignals()` also
555
+ // re-stamps `startedAtRef`, which would make a LATER Continue (after a
556
+ // VALIDATION round-trip) read as too-fast against the min-fill floor.
557
+ if (!detailsFirst) resetSignals();
417
558
  void refetchAvailability();
559
+ } else if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {
560
+ // These are DETAILS errors. In details-first the form is unmounted by
561
+ // now, so a toast would tell the visitor to fix an email they cannot
562
+ // see. Send them back to the form, which the stash repopulates.
563
+ setStep('details');
418
564
  } else {
419
565
  // The error surface is the TOAST, full stop (host-mounted Toaster —
420
566
  // every hub platform mounts it globally; embedders must too).
@@ -425,7 +571,7 @@ export function HubSpotMeetingScheduler({
425
571
  });
426
572
  }
427
573
  },
428
- [book, onBooked, refetchAvailability, resetSignals, toast],
574
+ [book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],
429
575
  );
430
576
 
431
577
  const escapeHatch = fallbackUrl ? (
@@ -436,39 +582,82 @@ export function HubSpotMeetingScheduler({
436
582
 
437
583
  // ---- terminal / degraded states -----------------------------------------
438
584
 
585
+ /**
586
+ * The card states ONE height and everything inside derives from it (see
587
+ * `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it
588
+ * gets its own fixed pair rather than a floor — a floor was tried here before
589
+ * and let the tall stage push the wrapper.
590
+ *
591
+ * Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and
592
+ * last-wins, so appending it after would make a host's own `h-*` unreachable,
593
+ * which is the override the height-inside-CARD_CLASS arrangement allows today.
594
+ */
595
+ const flowHeight = detailsFirst ? MEETING_SCHEDULER_DETAILS_FIRST_H : MEETING_SCHEDULER_H;
596
+
597
+ /**
598
+ * details-first, server render, seed missing entirely: land on the degraded
599
+ * card rather than a grey calendar above the fold.
600
+ *
601
+ * Gated on `!hydrated`, NOT `!isFetchingAvailability`: React Query's
602
+ * optimistic-results path runs during render with nothing subscribed, so
603
+ * `isFetching` is TRUE on the server and that predicate would be dead exactly
604
+ * where it must fire. Gated on the details step too, so month paging (which
605
+ * only happens at `step === 'slot'`) can never reach it.
606
+ */
607
+ const unseededDetailsFirstFailure =
608
+ detailsFirst && step === 'details' && !availability && !initialAvailability && !hydrated;
609
+
610
+ /** One card SHAPE for both degraded returns below — a new return, not a new
611
+ * design. */
612
+ const degradedCard = (
613
+ <div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
614
+ <p className="text-ods-text-secondary text-h6">
615
+ We couldn&apos;t load available call times. Please try again shortly.
616
+ </p>
617
+ {escapeHatch}
618
+ </div>
619
+ );
620
+
621
+ // NARROW, and above the cold-start branch on purpose. ORing this into the
622
+ // `availabilityError || !availability` return below (or hoisting that return
623
+ // up here) would swallow the cold start for EVERY flow: its predicate is
624
+ // strictly broader, so slot-first would answer an unseeded mount — and every
625
+ // month page, which is a fresh query key with no initialData — with "we
626
+ // couldn't load available call times" where it renders the skeleton today.
627
+ if (unseededDetailsFirstFailure) return degradedCard;
628
+
439
629
  if (isLoadingAvailability && !availability) {
440
630
  // COLD start only — a month already in the query cache renders straight
441
631
  // into the loaded card. Composed inside the SAME wrappers as that card
442
632
  // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing
443
633
  // shifts when it swaps.
444
634
  return (
445
- <div className={cn(CARD_CLASS, className)}>
635
+ <div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
446
636
  <div className={CARD_INNER_CLASS}>
447
- <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />
637
+ {!formOnly && <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />}
448
638
  <div className={ACTION_PANEL_CLASS}>
449
- <SlotPickerSkeleton monthOffset={monthOffset} />
639
+ {/* Both terms matter. Without `flow` this stays a calendar where
640
+ the form belongs; without the step term, paging a month AFTER
641
+ Continue (which happens at `step === 'slot'`) would swap the
642
+ calendar out for a form skeleton and back. */}
643
+ {detailsFirst && step === 'details' ? (
644
+ <BookingFormSkeleton />
645
+ ) : (
646
+ <SlotPickerSkeleton monthOffset={monthOffset} />
647
+ )}
450
648
  </div>
451
649
  </div>
452
650
  </div>
453
651
  );
454
652
  }
455
653
 
456
- if (availabilityError || !availability) {
457
- return (
458
- <div className={cn(CARD_DEGRADED_CLASS, className)}>
459
- <p className="text-ods-text-secondary text-h6">
460
- We couldn&apos;t load available call times. Please try again shortly.
461
- </p>
462
- {escapeHatch}
463
- </div>
464
- );
465
- }
654
+ if (availabilityError || !availability) return degradedCard;
466
655
 
467
656
  if (!isNativelyBookable(availability)) {
468
657
  // Fail closed — never render a half-working native form on a link with
469
658
  // questions or consent we can't faithfully reproduce.
470
659
  return (
471
- <div className={cn(CARD_DEGRADED_CLASS, className)}>
660
+ <div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
472
661
  <p className="text-ods-text-secondary text-h6">This meeting type is booked directly on HubSpot.</p>
473
662
  {escapeHatch}
474
663
  </div>
@@ -478,36 +667,51 @@ export function HubSpotMeetingScheduler({
478
667
  // ---- the card ------------------------------------------------------------
479
668
 
480
669
  return (
481
- <div className={cn(CARD_CLASS, className)}>
670
+ <div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
482
671
  <div className={CARD_INNER_CLASS}>
483
- <SchedulerContextPanel
484
- hosts={shownHosts}
485
- title={title}
486
- description={description}
487
- durationsMs={durations}
488
- selectedDurationMs={durationMs}
489
- onSelectDuration={ms => {
490
- setDurationMs(ms);
491
- setSelectedDay(null);
492
- setSelectedSlot(null);
493
- if (step === 'details') setStep('slot');
494
- }}
495
- timezone={timezone}
496
- onTimezoneChange={setTimezone}
497
- // ONE back edge for the whole card, in the panel that never swaps.
498
- // Its destination is simply "the previous step": from the form back
499
- // to the calendar, from the calendar out of the scheduler (if the
500
- // host gave us an exit at all). Two Backs on screen reading the same
501
- // word with different destinations was the ambiguity the details
502
- // step used to carry.
503
- onBack={step === 'details' ? backToSlot : onBack}
504
- locked={step !== 'slot'}
505
- // The zone still governs every time on screen — including the
506
- // summary line on the form — so the picker stays until there is
507
- // nothing left to re-read in it.
508
- showTimezone={step !== 'confirmed'}
509
- className={CONTEXT_PANEL_CLASS}
510
- />
672
+ {!formOnly && (
673
+ <SchedulerContextPanel
674
+ hosts={shownHosts}
675
+ title={title}
676
+ description={description}
677
+ durationsMs={durations}
678
+ selectedDurationMs={durationMs}
679
+ onSelectDuration={ms => {
680
+ setDurationMs(ms);
681
+ setSelectedDay(null);
682
+ setSelectedSlot(null);
683
+ // slot-first only. In details-first `details` is step ONE, so this
684
+ // would yank a mid-typing visitor onto the calendar and unmount the
685
+ // form under them.
686
+ if (!detailsFirst && step === 'details') setStep('slot');
687
+ }}
688
+ timezone={timezone}
689
+ onTimezoneChange={setTimezone}
690
+ // ONE back edge for the whole card, in the panel that never swaps.
691
+ // Its destination is simply "the previous step": from the form back
692
+ // to the calendar, from the calendar out of the scheduler (if the
693
+ // host gave us an exit at all). Two Backs on screen reading the same
694
+ // word with different destinations was the ambiguity the details
695
+ // step used to carry.
696
+ // details-first inverts the mapping: the form is step ONE, so Back at
697
+ // `details` is the host's exit and Back at `slot` returns to the form.
698
+ onBack={
699
+ detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack
700
+ }
701
+ // In details-first the duration/zone selectors stay live while the
702
+ // form is filled — but must freeze once a POST is in flight, or a
703
+ // mid-flight change clears the slot under the spinner while the body
704
+ // already built carries the old duration.
705
+ locked={detailsFirst ? step === 'confirmed' || isSubmitting : step !== 'slot'}
706
+ // The zone still governs every time on screen — including the
707
+ // summary line on the form — so the picker stays until there is
708
+ // nothing left to re-read in it.
709
+ showTimezone={step !== 'confirmed'}
710
+ // The mock's calendar step states the length, it does not offer it.
711
+ fixedDuration={detailsFirst}
712
+ className={CONTEXT_PANEL_CLASS}
713
+ />
714
+ )}
511
715
 
512
716
  {/* Scrolls INSIDE the fixed card rather than growing it — see
513
717
  MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's
@@ -517,32 +721,50 @@ export function HubSpotMeetingScheduler({
517
721
  <div className={cn('flex flex-1 flex-col', PANEL_STEP_CLASS)}>
518
722
  <Confirmation confirmation={confirmation} timezone={timezone} />
519
723
  </div>
520
- ) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
724
+ ) : step === 'details' &&
725
+ (detailsFirst
726
+ ? // The form is step ONE here: no slot yet, and `timezone` is null
727
+ // on the server render, so neither may gate the PANEL. A link
728
+ // publishing no durations has no form worth showing.
729
+ durations.length > 0
730
+ : durationMs != null && selectedSlot != null && timezone) ? (
521
731
  <div className={cn('flex flex-1 flex-col gap-[var(--spacing-system-m)]', PANEL_STEP_CLASS)}>
522
732
  {/* Top-aligned, and no back edge of its own: the ONE back edge
523
733
  lives in the context panel at every step (see `contextBack`),
524
734
  which is where the design puts it and the only spot that
525
- stays put while this side swaps between calendar and form. */}
526
- <p className="text-ods-text-primary text-h4">
527
- {new Intl.DateTimeFormat(undefined, {
528
- timeZone: timezone,
529
- weekday: 'long',
530
- month: 'long',
531
- day: 'numeric',
532
- hour: 'numeric',
533
- minute: '2-digit',
534
- timeZoneName: 'short',
535
- }).format(new Date(selectedSlot))}{' '}
536
- · {formatDurationCompact(durationMs / 1000)}
537
- </p>
538
- <BookingForm
735
+ stays put while this side swaps between calendar and form.
736
+
737
+ The summary keeps its OWN guard — including `timezone`, which
738
+ is null pre-hydration and which `Intl.DateTimeFormat` throws a
739
+ RangeError on rather than ignoring. */}
740
+ {selectedSlot != null && durationMs != null && timezone != null && (
741
+ <p className="text-ods-text-primary text-h4">
742
+ {new Intl.DateTimeFormat(undefined, {
743
+ timeZone: timezone,
744
+ weekday: 'long',
745
+ month: 'long',
746
+ day: 'numeric',
747
+ hour: 'numeric',
748
+ minute: '2-digit',
749
+ timeZoneName: 'short',
750
+ }).format(new Date(selectedSlot))}{' '}
751
+ · {formatDurationCompact(durationMs / 1000)}
752
+ </p>
753
+ )}
754
+ <DetailsForm
539
755
  availability={availability}
540
756
  meetingId={meetingId}
541
- startTimeMs={selectedSlot}
542
- durationMs={durationMs}
757
+ startTimeMs={selectedSlot ?? undefined}
758
+ durationMs={durationMs ?? undefined}
543
759
  timezone={timezone}
760
+ deferSlot={detailsFirst}
761
+ submitLabel={detailsFirst ? 'Continue' : undefined}
762
+ footerNote={
763
+ detailsFirst ? 'Next step: pick a slot that works. Calendar invite lands right after.' : undefined
764
+ }
765
+ initialValues={detailsFirst ? stash?.payload : undefined}
544
766
  isSubmitting={isSubmitting}
545
- onSubmit={handleSubmit}
767
+ onSubmit={detailsFirst ? stashDetails : handleSubmit}
546
768
  honeypotInputProps={honeypotInputProps}
547
769
  getSignals={getSignals}
548
770
  />
@@ -575,12 +797,38 @@ export function HubSpotMeetingScheduler({
575
797
  }}
576
798
  selectedSlot={selectedSlot}
577
799
  onSelectSlot={ms => {
800
+ if (!detailsFirst) {
801
+ setSelectedSlot(ms);
802
+ setStep('details');
803
+ return;
804
+ }
805
+ // details-first: the slot IS the submit. The step stays
806
+ // 'slot' for the whole POST — moving it here would unmount
807
+ // the chip the spinner lives on and fight the SLOT_TAKEN
808
+ // branch, which puts the visitor back on this calendar.
809
+ if (isSubmitting || !stash) return;
578
810
  setSelectedSlot(ms);
579
- setStep('details');
811
+ void handleSubmit({
812
+ ...stash.payload,
813
+ ...stash.signals,
814
+ meetingId,
815
+ // `ms`, NOT `selectedSlot` — the setState above has not
816
+ // committed inside this handler, so state holds null on
817
+ // the first booking and the PREVIOUS slot on a retry.
818
+ startTimeMs: ms,
819
+ // Live values: both stay editable after Continue, so the
820
+ // copies frozen at that point can be stale.
821
+ durationMs,
822
+ timezone,
823
+ });
580
824
  }}
581
825
  selectedDay={selectedDay}
582
826
  onSelectDay={setSelectedDay}
583
827
  isLoading={isLoadingAvailability}
828
+ // Distinct from `isLoading`, which swaps the whole times
829
+ // column for a skeleton: the grid must stay up with the
830
+ // clicked chip spinning.
831
+ isSubmitting={detailsFirst ? isSubmitting : undefined}
584
832
  />
585
833
  ) : durations.length === 0 && !isFetchingAvailability ? (
586
834
  // The LINK publishes nothing at all — a different thing from a
@@ -56,6 +56,13 @@ export interface SlotPickerProps {
56
56
  onSelectDay: (dayKey: string) => void;
57
57
  /** Availability refetch in flight (month change) → per-region skeletons. */
58
58
  isLoading?: boolean;
59
+ /**
60
+ * A booking POST is in flight (details-first, where the slot click IS the
61
+ * submit). DISTINCT from `isLoading`, which swaps the whole times column for
62
+ * a skeleton — here the grid must stay up with the clicked chip spinning, so
63
+ * the visitor can see WHICH time is being booked.
64
+ */
65
+ isSubmitting?: boolean;
59
66
  }
60
67
 
61
68
  /** Stable per-zone day key for an instant, e.g. "2026-08-14" (exported — the
@@ -330,6 +337,7 @@ export function SlotPicker({
330
337
  selectedDay,
331
338
  onSelectDay,
332
339
  isLoading = false,
340
+ isSubmitting = false,
333
341
  }: SlotPickerProps) {
334
342
  const slotsByDay = useMemo(() => {
335
343
  const map = new Map<string, number[]>();
@@ -391,7 +399,15 @@ export function SlotPicker({
391
399
  <Button
392
400
  key={ms}
393
401
  variant={isSelected ? undefined : 'outline'}
394
- onClick={() => onSelectSlot(ms)}
402
+ // The click is the submit in details-first, so the grid
403
+ // itself is the double-book guard — the form button that
404
+ // used to carry `disabled={isSubmitting}` is not on screen.
405
+ disabled={isSubmitting}
406
+ loading={isSubmitting && isSelected}
407
+ onClick={() => {
408
+ if (isSubmitting) return;
409
+ onSelectSlot(ms);
410
+ }}
395
411
  className={CHIP_CLASS}
396
412
  >
397
413
  {timeLabelInZone(ms, timezone)}