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

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 +49 -5
  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 +369 -186
  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 +344 -161
  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 +371 -152
  131. package/src/components/meeting-scheduler/context-panel.tsx +13 -3
  132. package/src/components/meeting-scheduler/index.tsx +316 -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
@@ -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,18 @@ 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
+ } from './booking-form';
54
66
  import { Confirmation } from './confirmation';
55
67
  import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
56
68
  import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
@@ -101,10 +113,48 @@ export interface HubSpotMeetingSchedulerProps {
101
113
  onBack?: () => void;
102
114
  onBooked?: (b: BookingConfirmation) => void;
103
115
  className?: string;
116
+ /**
117
+ * Panel order.
118
+ *
119
+ * `'slot-first'` (default, unchanged): calendar → details → confirmed.
120
+ * `'details-first'`: the form comes FIRST and the slot click submits the
121
+ * already-validated payload. Campaign landing pages use it, where "tell us
122
+ * about your setup, then pick a time" is the conversion shape.
123
+ *
124
+ * This is not a cosmetic swap — see the flow-dependent branches below. It is
125
+ * opt-in precisely so `slot-first` stays byte-identical.
126
+ */
127
+ flow?: 'slot-first' | 'details-first';
128
+ /**
129
+ * Replace the details panel — step ONE under `flow="details-first"`, step two
130
+ * otherwise — with a host-supplied form. It receives exactly what the built-in
131
+ * one does, so the injected form keeps the widget's contract: the deferred
132
+ * schema, the honeypot and elapsed-ms signals, the verbatim consent block, the
133
+ * `onSubmit` handoff and the `isSubmitting` lock.
134
+ *
135
+ * The intended shape is a THIN wrapper that re-renders `BookingForm` with
136
+ * `fieldRows` (a layout re-arrangement, all machinery reused), not a
137
+ * hand-written form. Anything reimplementing the contract loses the bot
138
+ * protection and the consent guarantees.
139
+ *
140
+ * A COMPONENT type, not a render callback: React then owns its identity, so
141
+ * it reconciles across steps and keeps its own state instead of remounting —
142
+ * and the honeypot `ref` rides through as a normal prop.
143
+ *
144
+ * Defaults to the built-in form, so every existing embed is untouched.
145
+ */
146
+ detailsForm?: ComponentType<BookingFormProps>;
104
147
  }
105
148
 
106
149
  type Step = 'slot' | 'details' | 'confirmed';
107
150
 
151
+ /** Values collected at the details step, held until a slot picks the instant. */
152
+ type StashedDetails = {
153
+ payload: Record<string, unknown>;
154
+ /** Humanity signals captured while the form (and its honeypot) was mounted. */
155
+ signals: Record<string, string | number>;
156
+ };
157
+
108
158
  /** Context-panel geometry — ONE definition for the loaded card and the
109
159
  * loading skeleton, which is what keeps the two footprint-identical. */
110
160
  const CONTEXT_PANEL_CLASS =
@@ -184,6 +234,27 @@ const PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-a
184
234
  */
185
235
  export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
186
236
 
237
+ /**
238
+ * The same box for `flow="details-first"`, where the tallest stage is the
239
+ * DETAILS FORM (email + first/last + the link's declared questions + the
240
+ * consent block + Continue) rather than the calendar.
241
+ *
242
+ * Still a fixed pair, not a floor: everything below the card derives a definite
243
+ * height from it, and a floor was already tried here — it pinned the short
244
+ * stages and let the tall one push the wrapper, which is the screen-shake the
245
+ * fixed height exists to remove.
246
+ *
247
+ * ONE number from `md` up, not two: 638px, the card both mocks draw
248
+ * (`4904:117130` form-only, `4904:118213` three columns). The form stage has no
249
+ * sidebar to stack into a header strip, so nothing about it changes between
250
+ * tablet and desktop, and the calendar stage fits the same box at both — the
251
+ * times column scrolls inside it as it always has.
252
+ *
253
+ * A host reserving the box for a details-first card reserves THIS, not the
254
+ * constant above.
255
+ */
256
+ export const MEETING_SCHEDULER_DETAILS_FIRST_H = 'md:h-[39.875rem]';
257
+
187
258
  /**
188
259
  * The two-panel card's box, shared verbatim by the loading skeleton and the
189
260
  * loaded card so neither can drift from the other.
@@ -214,7 +285,9 @@ export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
214
285
  const CARD_CLASS = cn(
215
286
  'overflow-hidden rounded-md border border-ods-border bg-ods-card',
216
287
  'md:flex md:flex-col',
217
- MEETING_SCHEDULER_H,
288
+ // The height is NOT baked in any more: it is chosen per flow at each render
289
+ // site (`flowHeight`), because details-first needs a taller box and both
290
+ // constants are module scope.
218
291
  );
219
292
 
220
293
  /** Context strip over action panel, side by side from `lg`. `md:flex-1
@@ -231,7 +304,6 @@ const CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-r
231
304
  const CARD_DEGRADED_CLASS = cn(
232
305
  '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
306
  'md:justify-center md:overflow-y-auto',
234
- MEETING_SCHEDULER_H,
235
307
  );
236
308
 
237
309
  /**
@@ -262,6 +334,8 @@ export function HubSpotMeetingScheduler({
262
334
  onBack,
263
335
  onBooked,
264
336
  className,
337
+ flow = 'slot-first',
338
+ detailsForm: DetailsForm = BookingForm,
265
339
  }: HubSpotMeetingSchedulerProps) {
266
340
  const {
267
341
  availability,
@@ -275,6 +349,8 @@ export function HubSpotMeetingScheduler({
275
349
  isSubmitting,
276
350
  } = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });
277
351
 
352
+ const detailsFirst = flow === 'details-first';
353
+
278
354
  // Zone resolution happens POST-mount (Intl) unless the embedder pins one —
279
355
  // the initial (server) render stays deterministic; time labels appear once
280
356
  // a zone exists.
@@ -297,7 +373,20 @@ export function HubSpotMeetingScheduler({
297
373
  }, [hydrated]);
298
374
  const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;
299
375
 
300
- const [step, setStep] = useState<Step>('slot');
376
+ const [step, setStep] = useState<Step>(detailsFirst ? 'details' : 'slot');
377
+ // State, not a ref: the link-swap reset below writes it DURING RENDER, and
378
+ // this file's own rule forbids writing a ref there. "Frozen" means written
379
+ // once at Continue, not `useRef`.
380
+ const [stash, setStash] = useState<StashedDetails | null>(null);
381
+
382
+ /**
383
+ * details-first step ONE is the form and nothing else — the mock
384
+ * (`4904:117130`) draws no context panel beside it, and paints the card on the
385
+ * page ground so the fields read as the raised elements. The panel (host,
386
+ * fixed length, timezone, and the Back edge to this form) belongs to the
387
+ * calendar step, exactly as slot-first has always drawn it.
388
+ */
389
+ const formOnly = detailsFirst && step === 'details';
301
390
  const [pickedDurationMs, setDurationMs] = useState<number | null>(null);
302
391
  const [pickedDay, setSelectedDay] = useState<string | null>(null);
303
392
  const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
@@ -316,12 +405,16 @@ export function HubSpotMeetingScheduler({
316
405
  const [machineFor, setMachineFor] = useState(meetingId);
317
406
  if (machineFor !== meetingId) {
318
407
  setMachineFor(meetingId);
319
- setStep('slot');
408
+ setStep(detailsFirst ? 'details' : 'slot');
320
409
  setDurationMs(null);
321
410
  setSelectedDay(null);
322
411
  setSelectedSlot(null);
323
412
  setConfirmation(null);
324
413
  setBookingError(null);
414
+ // Clearing the stash is load-bearing: otherwise the swapped-in link would
415
+ // POST the PREVIOUS link's answers, against a schema the server rebuilds
416
+ // from the new link's metadata.
417
+ setStash(null);
325
418
  }
326
419
  useEffect(() => {
327
420
  autoAdvanceCount.current = 0;
@@ -396,10 +489,53 @@ export function HubSpotMeetingScheduler({
396
489
  setBookingError(null);
397
490
  }, []);
398
491
 
492
+ /** details-first's inverse: back from the CALENDAR to the form. The stash is
493
+ * what repopulates it, so nothing is cleared here but the error. */
494
+ const backToDetails = useCallback(() => {
495
+ setStep('details');
496
+ setBookingError(null);
497
+ }, []);
498
+
499
+ /**
500
+ * details-first: Continue does not POST. It validates, freezes the answers
501
+ * and the humanity signals (captured HERE, while the form and its honeypot
502
+ * are still mounted — `getSignals()` reads a detached ref once they unmount),
503
+ * and hands over to the calendar.
504
+ */
505
+ const stashDetails = useCallback(
506
+ (payload: Record<string, unknown>): Promise<void> => {
507
+ setBookingError(null);
508
+ setStash({ payload, signals: getSignals() });
509
+ setStep('slot');
510
+ // The visitor has been filling a form; the month they are about to see
511
+ // may have moved on, and its hop budget should start fresh.
512
+ autoAdvanceCount.current = 0;
513
+ void refetchAvailability();
514
+ // Deliberately NOT `async`: nothing here is awaited. The refetch is
515
+ // fire-and-forget so the calendar paints from cache the moment the step
516
+ // flips, and awaiting it would hold `BookingForm`'s isSubmitting past
517
+ // the unmount. `onSubmit` is Promise-returning, hence the explicit
518
+ // resolve rather than an empty async body (which `require-await` flags).
519
+ return Promise.resolve();
520
+ },
521
+ [getSignals, refetchAvailability],
522
+ );
523
+
399
524
  const handleSubmit = useCallback(
400
525
  async (payload: Record<string, unknown>) => {
526
+ // Captured BEFORE the clear below: `book()` reports a re-entrant call as
527
+ // a VALIDATION code carrying the message 'Already submitting', and by
528
+ // then this clear has already run — so a double-click would wipe a
529
+ // visible SLOT_TAKEN alert on its way to being ignored.
530
+ const priorError = bookingError;
401
531
  setBookingError(null);
402
532
  const result = await book(payload);
533
+
534
+ // Not an error: the hook's in-flight guard. No toast, no step change.
535
+ if (!result.ok && result.message === 'Already submitting') {
536
+ setBookingError(priorError);
537
+ return;
538
+ }
403
539
  if (result.ok && result.confirmation) {
404
540
  setConfirmation(result.confirmation);
405
541
  setStep('confirmed');
@@ -413,8 +549,17 @@ export function HubSpotMeetingScheduler({
413
549
  // signal so the retry isn't flagged too-fast.
414
550
  setSelectedSlot(null);
415
551
  setStep('slot');
416
- resetSignals();
552
+ // NOT in details-first: the retry re-POSTs the frozen stash, so
553
+ // `getSignals()` is never consulted again — but `resetSignals()` also
554
+ // re-stamps `startedAtRef`, which would make a LATER Continue (after a
555
+ // VALIDATION round-trip) read as too-fast against the min-fill floor.
556
+ if (!detailsFirst) resetSignals();
417
557
  void refetchAvailability();
558
+ } else if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {
559
+ // These are DETAILS errors. In details-first the form is unmounted by
560
+ // now, so a toast would tell the visitor to fix an email they cannot
561
+ // see. Send them back to the form, which the stash repopulates.
562
+ setStep('details');
418
563
  } else {
419
564
  // The error surface is the TOAST, full stop (host-mounted Toaster —
420
565
  // every hub platform mounts it globally; embedders must too).
@@ -425,7 +570,7 @@ export function HubSpotMeetingScheduler({
425
570
  });
426
571
  }
427
572
  },
428
- [book, onBooked, refetchAvailability, resetSignals, toast],
573
+ [book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],
429
574
  );
430
575
 
431
576
  const escapeHatch = fallbackUrl ? (
@@ -436,39 +581,82 @@ export function HubSpotMeetingScheduler({
436
581
 
437
582
  // ---- terminal / degraded states -----------------------------------------
438
583
 
584
+ /**
585
+ * The card states ONE height and everything inside derives from it (see
586
+ * `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it
587
+ * gets its own fixed pair rather than a floor — a floor was tried here before
588
+ * and let the tall stage push the wrapper.
589
+ *
590
+ * Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and
591
+ * last-wins, so appending it after would make a host's own `h-*` unreachable,
592
+ * which is the override the height-inside-CARD_CLASS arrangement allows today.
593
+ */
594
+ const flowHeight = detailsFirst ? MEETING_SCHEDULER_DETAILS_FIRST_H : MEETING_SCHEDULER_H;
595
+
596
+ /**
597
+ * details-first, server render, seed missing entirely: land on the degraded
598
+ * card rather than a grey calendar above the fold.
599
+ *
600
+ * Gated on `!hydrated`, NOT `!isFetchingAvailability`: React Query's
601
+ * optimistic-results path runs during render with nothing subscribed, so
602
+ * `isFetching` is TRUE on the server and that predicate would be dead exactly
603
+ * where it must fire. Gated on the details step too, so month paging (which
604
+ * only happens at `step === 'slot'`) can never reach it.
605
+ */
606
+ const unseededDetailsFirstFailure =
607
+ detailsFirst && step === 'details' && !availability && !initialAvailability && !hydrated;
608
+
609
+ /** One card SHAPE for both degraded returns below — a new return, not a new
610
+ * design. */
611
+ const degradedCard = (
612
+ <div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
613
+ <p className="text-ods-text-secondary text-h6">
614
+ We couldn&apos;t load available call times. Please try again shortly.
615
+ </p>
616
+ {escapeHatch}
617
+ </div>
618
+ );
619
+
620
+ // NARROW, and above the cold-start branch on purpose. ORing this into the
621
+ // `availabilityError || !availability` return below (or hoisting that return
622
+ // up here) would swallow the cold start for EVERY flow: its predicate is
623
+ // strictly broader, so slot-first would answer an unseeded mount — and every
624
+ // month page, which is a fresh query key with no initialData — with "we
625
+ // couldn't load available call times" where it renders the skeleton today.
626
+ if (unseededDetailsFirstFailure) return degradedCard;
627
+
439
628
  if (isLoadingAvailability && !availability) {
440
629
  // COLD start only — a month already in the query cache renders straight
441
630
  // into the loaded card. Composed inside the SAME wrappers as that card
442
631
  // (`ContextPanelSkeleton` beside the real slot-area layout), so nothing
443
632
  // shifts when it swaps.
444
633
  return (
445
- <div className={cn(CARD_CLASS, className)}>
634
+ <div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
446
635
  <div className={CARD_INNER_CLASS}>
447
- <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />
636
+ {!formOnly && <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />}
448
637
  <div className={ACTION_PANEL_CLASS}>
449
- <SlotPickerSkeleton monthOffset={monthOffset} />
638
+ {/* Both terms matter. Without `flow` this stays a calendar where
639
+ the form belongs; without the step term, paging a month AFTER
640
+ Continue (which happens at `step === 'slot'`) would swap the
641
+ calendar out for a form skeleton and back. */}
642
+ {detailsFirst && step === 'details' ? (
643
+ <BookingFormSkeleton />
644
+ ) : (
645
+ <SlotPickerSkeleton monthOffset={monthOffset} />
646
+ )}
450
647
  </div>
451
648
  </div>
452
649
  </div>
453
650
  );
454
651
  }
455
652
 
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
- }
653
+ if (availabilityError || !availability) return degradedCard;
466
654
 
467
655
  if (!isNativelyBookable(availability)) {
468
656
  // Fail closed — never render a half-working native form on a link with
469
657
  // questions or consent we can't faithfully reproduce.
470
658
  return (
471
- <div className={cn(CARD_DEGRADED_CLASS, className)}>
659
+ <div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
472
660
  <p className="text-ods-text-secondary text-h6">This meeting type is booked directly on HubSpot.</p>
473
661
  {escapeHatch}
474
662
  </div>
@@ -478,36 +666,51 @@ export function HubSpotMeetingScheduler({
478
666
  // ---- the card ------------------------------------------------------------
479
667
 
480
668
  return (
481
- <div className={cn(CARD_CLASS, className)}>
669
+ <div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
482
670
  <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
- />
671
+ {!formOnly && (
672
+ <SchedulerContextPanel
673
+ hosts={shownHosts}
674
+ title={title}
675
+ description={description}
676
+ durationsMs={durations}
677
+ selectedDurationMs={durationMs}
678
+ onSelectDuration={ms => {
679
+ setDurationMs(ms);
680
+ setSelectedDay(null);
681
+ setSelectedSlot(null);
682
+ // slot-first only. In details-first `details` is step ONE, so this
683
+ // would yank a mid-typing visitor onto the calendar and unmount the
684
+ // form under them.
685
+ if (!detailsFirst && step === 'details') setStep('slot');
686
+ }}
687
+ timezone={timezone}
688
+ onTimezoneChange={setTimezone}
689
+ // ONE back edge for the whole card, in the panel that never swaps.
690
+ // Its destination is simply "the previous step": from the form back
691
+ // to the calendar, from the calendar out of the scheduler (if the
692
+ // host gave us an exit at all). Two Backs on screen reading the same
693
+ // word with different destinations was the ambiguity the details
694
+ // step used to carry.
695
+ // details-first inverts the mapping: the form is step ONE, so Back at
696
+ // `details` is the host's exit and Back at `slot` returns to the form.
697
+ onBack={
698
+ detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack
699
+ }
700
+ // In details-first the duration/zone selectors stay live while the
701
+ // form is filled — but must freeze once a POST is in flight, or a
702
+ // mid-flight change clears the slot under the spinner while the body
703
+ // already built carries the old duration.
704
+ locked={detailsFirst ? step === 'confirmed' || isSubmitting : step !== 'slot'}
705
+ // The zone still governs every time on screen — including the
706
+ // summary line on the form — so the picker stays until there is
707
+ // nothing left to re-read in it.
708
+ showTimezone={step !== 'confirmed'}
709
+ // The mock's calendar step states the length, it does not offer it.
710
+ fixedDuration={detailsFirst}
711
+ className={CONTEXT_PANEL_CLASS}
712
+ />
713
+ )}
511
714
 
512
715
  {/* Scrolls INSIDE the fixed card rather than growing it — see
513
716
  MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's
@@ -517,32 +720,50 @@ export function HubSpotMeetingScheduler({
517
720
  <div className={cn('flex flex-1 flex-col', PANEL_STEP_CLASS)}>
518
721
  <Confirmation confirmation={confirmation} timezone={timezone} />
519
722
  </div>
520
- ) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
723
+ ) : step === 'details' &&
724
+ (detailsFirst
725
+ ? // The form is step ONE here: no slot yet, and `timezone` is null
726
+ // on the server render, so neither may gate the PANEL. A link
727
+ // publishing no durations has no form worth showing.
728
+ durations.length > 0
729
+ : durationMs != null && selectedSlot != null && timezone) ? (
521
730
  <div className={cn('flex flex-1 flex-col gap-[var(--spacing-system-m)]', PANEL_STEP_CLASS)}>
522
731
  {/* Top-aligned, and no back edge of its own: the ONE back edge
523
732
  lives in the context panel at every step (see `contextBack`),
524
733
  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
734
+ stays put while this side swaps between calendar and form.
735
+
736
+ The summary keeps its OWN guard — including `timezone`, which
737
+ is null pre-hydration and which `Intl.DateTimeFormat` throws a
738
+ RangeError on rather than ignoring. */}
739
+ {selectedSlot != null && durationMs != null && timezone != null && (
740
+ <p className="text-ods-text-primary text-h4">
741
+ {new Intl.DateTimeFormat(undefined, {
742
+ timeZone: timezone,
743
+ weekday: 'long',
744
+ month: 'long',
745
+ day: 'numeric',
746
+ hour: 'numeric',
747
+ minute: '2-digit',
748
+ timeZoneName: 'short',
749
+ }).format(new Date(selectedSlot))}{' '}
750
+ · {formatDurationCompact(durationMs / 1000)}
751
+ </p>
752
+ )}
753
+ <DetailsForm
539
754
  availability={availability}
540
755
  meetingId={meetingId}
541
- startTimeMs={selectedSlot}
542
- durationMs={durationMs}
756
+ startTimeMs={selectedSlot ?? undefined}
757
+ durationMs={durationMs ?? undefined}
543
758
  timezone={timezone}
759
+ deferSlot={detailsFirst}
760
+ submitLabel={detailsFirst ? 'Continue' : undefined}
761
+ footerNote={
762
+ detailsFirst ? 'Next step: pick a slot that works. Calendar invite lands right after.' : undefined
763
+ }
764
+ initialValues={detailsFirst ? stash?.payload : undefined}
544
765
  isSubmitting={isSubmitting}
545
- onSubmit={handleSubmit}
766
+ onSubmit={detailsFirst ? stashDetails : handleSubmit}
546
767
  honeypotInputProps={honeypotInputProps}
547
768
  getSignals={getSignals}
548
769
  />
@@ -575,12 +796,38 @@ export function HubSpotMeetingScheduler({
575
796
  }}
576
797
  selectedSlot={selectedSlot}
577
798
  onSelectSlot={ms => {
799
+ if (!detailsFirst) {
800
+ setSelectedSlot(ms);
801
+ setStep('details');
802
+ return;
803
+ }
804
+ // details-first: the slot IS the submit. The step stays
805
+ // 'slot' for the whole POST — moving it here would unmount
806
+ // the chip the spinner lives on and fight the SLOT_TAKEN
807
+ // branch, which puts the visitor back on this calendar.
808
+ if (isSubmitting || !stash) return;
578
809
  setSelectedSlot(ms);
579
- setStep('details');
810
+ void handleSubmit({
811
+ ...stash.payload,
812
+ ...stash.signals,
813
+ meetingId,
814
+ // `ms`, NOT `selectedSlot` — the setState above has not
815
+ // committed inside this handler, so state holds null on
816
+ // the first booking and the PREVIOUS slot on a retry.
817
+ startTimeMs: ms,
818
+ // Live values: both stay editable after Continue, so the
819
+ // copies frozen at that point can be stale.
820
+ durationMs,
821
+ timezone,
822
+ });
580
823
  }}
581
824
  selectedDay={selectedDay}
582
825
  onSelectDay={setSelectedDay}
583
826
  isLoading={isLoadingAvailability}
827
+ // Distinct from `isLoading`, which swaps the whole times
828
+ // column for a skeleton: the grid must stay up with the
829
+ // clicked chip spinning.
830
+ isSubmitting={detailsFirst ? isSubmitting : undefined}
584
831
  />
585
832
  ) : durations.length === 0 && !isFetchingAvailability ? (
586
833
  // 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)}
@@ -18,24 +18,24 @@ export const BenefitCard: React.FC<BenefitCardProps> = ({
18
18
  variant = 'default',
19
19
  className = '',
20
20
  }) => {
21
- const getBaseStyles = (cardVariant: string) => {
22
- const gap = cardVariant === 'auth-figma' ? 'gap-4' : 'gap-2';
23
- return `flex ${gap} items-start justify-start relative`;
24
- };
21
+ // Direction and cross-axis alignment only — every other per-variant value
22
+ // (including `gap`, which used to hide in a ternary here) lives in
23
+ // `variantStyles` so one variant is one place.
24
+ const baseStyles = 'flex items-start justify-start relative';
25
25
 
26
26
  const variantStyles = {
27
27
  default: {
28
- container: 'bg-ods-bg p-2 md:p-4',
28
+ container: 'gap-2 bg-ods-bg p-2 md:p-4',
29
29
  title: 'font-body font-bold text-body-md md:text-body-lg text-ods-text-primary',
30
30
  description: 'font-body font-medium text-body-sm md:text-body-md text-ods-text-secondary',
31
31
  },
32
32
  dark: {
33
- container: 'bg-transparent p-0 shadow-[0px_48px_80px_0px_rgba(0,0,0,0.24)]',
33
+ container: 'gap-2 bg-transparent p-0 shadow-[0px_48px_80px_0px_rgba(0,0,0,0.24)]',
34
34
  title: 'font-mono font-semibold text-heading-4 leading-[40px] text-ods-text-primary tracking-[-0.64px]',
35
35
  description: 'font-body font-medium text-body-lg leading-6 text-ods-text-tertiary',
36
36
  },
37
37
  'auth-figma': {
38
- container: 'bg-transparent p-6',
38
+ container: 'gap-4 bg-transparent p-6',
39
39
  title: 'text-h3 text-ods-text-primary tracking-[-0.36px]',
40
40
  description: 'text-h4 text-ods-text-secondary',
41
41
  },
@@ -44,7 +44,7 @@ export const BenefitCard: React.FC<BenefitCardProps> = ({
44
44
  const styles = variantStyles[variant];
45
45
 
46
46
  return (
47
- <div className={cn(getBaseStyles(variant), styles.container, className)}>
47
+ <div className={cn(baseStyles, styles.container, className)}>
48
48
  {icon && <div>{icon}</div>}
49
49
  <div className="flex min-w-0 flex-1 flex-col gap-1">
50
50
  <h3 className={cn(styles.title)}>{title}</h3>
@@ -93,8 +93,29 @@ export const BenefitCardGrid: React.FC<BenefitCardGridProps> = ({ children, clas
93
93
  !isInLastRow && 'border-b md:border-b lg:border-b-0',
94
94
  index < 2 && 'lg:border-b-0',
95
95
  );
96
+ } else if (columns === 3) {
97
+ // 3 columns lay out 1-up / 2-up / 3-up, so the row a card sits in
98
+ // differs per breakpoint and a single rule cannot describe it. The
99
+ // old shared 2-3 branch assumed one row and, at `md` (grid-cols-2),
100
+ // gave card 1 a trailing `md:border-r` while suppressing the divider
101
+ // between the two rows.
102
+ const isLastInMdRow = (index + 1) % 2 === 0;
103
+ const isInLastMdRow = index >= totalItems - (totalItems % 2 === 0 ? 2 : 1);
104
+ const isLastInLgRow = (index + 1) % 3 === 0;
105
+ const isInLastLgRow = index >= totalItems - (totalItems % 3 === 0 ? 3 : totalItems % 3);
106
+ borderClass = cn(
107
+ 'border-ods-border',
108
+ !isLastItem && 'border-b',
109
+ isInLastMdRow && 'md:border-b-0',
110
+ !isLastInMdRow && !isLastItem && 'md:border-r',
111
+ isLastInMdRow && 'md:border-r-0',
112
+ isInLastLgRow && 'lg:border-b-0',
113
+ !isInLastMdRow && !isInLastLgRow && 'lg:border-b',
114
+ !isLastInLgRow && !isLastItem && 'lg:border-r',
115
+ isLastInLgRow && 'lg:border-r-0',
116
+ );
96
117
  } else {
97
- // Original 2-3 column logic
118
+ // 2 columns: a flex row, so one rule genuinely does describe it.
98
119
  borderClass = isLastItem ? 'border-b-0' : 'border-b md:border-b-0 md:border-r border-ods-border';
99
120
  }
100
121