@flamingo-stack/openframe-frontend-core 0.0.595 → 0.0.596-2034.5670.1

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 (156) hide show
  1. package/dist/{chunk-U4QRK4MX.js → chunk-27U3NNFR.js} +4 -4
  2. package/dist/{chunk-OGE4GSLZ.cjs → chunk-2EPDTX4M.cjs} +84 -84
  3. package/dist/{chunk-OGE4GSLZ.cjs.map → chunk-2EPDTX4M.cjs.map} +1 -1
  4. package/dist/{chunk-BKMSI2AW.js → chunk-2KQO7OMB.js} +2 -2
  5. package/dist/{chunk-A57HKYLY.js → chunk-2NZKBKO6.js} +6 -6
  6. package/dist/{chunk-CPWX46TL.cjs → chunk-53HSPYKM.cjs} +6 -6
  7. package/dist/{chunk-CPWX46TL.cjs.map → chunk-53HSPYKM.cjs.map} +1 -1
  8. package/dist/{chunk-FHEN27GM.cjs → chunk-5K6GZANU.cjs} +11 -11
  9. package/dist/{chunk-FHEN27GM.cjs.map → chunk-5K6GZANU.cjs.map} +1 -1
  10. package/dist/{chunk-23UJ4EBJ.cjs → chunk-7CKSLZT6.cjs} +12 -12
  11. package/dist/{chunk-23UJ4EBJ.cjs.map → chunk-7CKSLZT6.cjs.map} +1 -1
  12. package/dist/{chunk-T2JAJ7MR.js → chunk-7YUFH47E.js} +26 -13
  13. package/dist/chunk-7YUFH47E.js.map +1 -0
  14. package/dist/{chunk-OWWICTQR.cjs → chunk-AN4CB7U3.cjs} +24 -24
  15. package/dist/{chunk-OWWICTQR.cjs.map → chunk-AN4CB7U3.cjs.map} +1 -1
  16. package/dist/{chunk-3WZLPCJ7.js → chunk-BDLFPDAW.js} +4 -4
  17. package/dist/{chunk-QLJMXCB5.js → chunk-BHIJ5OGK.js} +5 -5
  18. package/dist/{chunk-AQ65NLGJ.js → chunk-E7MTTLKC.js} +3 -3
  19. package/dist/{chunk-MUNVPRT2.js → chunk-EVMHJB6X.js} +2 -2
  20. package/dist/{chunk-MOHSA65M.js → chunk-EZM5DNII.js} +2 -2
  21. package/dist/{chunk-7N2NJPUK.js → chunk-FPG4S3X4.js} +4 -4
  22. package/dist/{chunk-HGWC7ABT.cjs → chunk-FUHYCQLO.cjs} +63 -63
  23. package/dist/{chunk-HGWC7ABT.cjs.map → chunk-FUHYCQLO.cjs.map} +1 -1
  24. package/dist/{chunk-MHORK6KM.cjs → chunk-IIWB7VEC.cjs} +3 -3
  25. package/dist/{chunk-MHORK6KM.cjs.map → chunk-IIWB7VEC.cjs.map} +1 -1
  26. package/dist/{chunk-ZY3Z3F4X.cjs → chunk-IJY6E2YO.cjs} +49 -49
  27. package/dist/{chunk-ZY3Z3F4X.cjs.map → chunk-IJY6E2YO.cjs.map} +1 -1
  28. package/dist/{chunk-5EM6NGBK.cjs → chunk-KOQCA5X5.cjs} +17 -27
  29. package/dist/chunk-KOQCA5X5.cjs.map +1 -0
  30. package/dist/{chunk-YYHYCQD6.cjs → chunk-L7GNBUXF.cjs} +168 -168
  31. package/dist/{chunk-YYHYCQD6.cjs.map → chunk-L7GNBUXF.cjs.map} +1 -1
  32. package/dist/{chunk-A5AKGC4S.js → chunk-LPMDCQLL.js} +17 -27
  33. package/dist/chunk-LPMDCQLL.js.map +1 -0
  34. package/dist/{chunk-4ZY5MP46.cjs → chunk-LPWTGNNP.cjs} +15 -15
  35. package/dist/{chunk-4ZY5MP46.cjs.map → chunk-LPWTGNNP.cjs.map} +1 -1
  36. package/dist/{chunk-FKXDDJEQ.cjs → chunk-M4YQUHOR.cjs} +5 -5
  37. package/dist/{chunk-FKXDDJEQ.cjs.map → chunk-M4YQUHOR.cjs.map} +1 -1
  38. package/dist/{chunk-PYEJL2UG.js → chunk-MOKMFLKB.js} +3 -3
  39. package/dist/{chunk-ZRXJX3JK.js → chunk-NRGBR3SN.js} +6 -6
  40. package/dist/{chunk-DYXVQ24Q.js → chunk-ONZCED5F.js} +3 -3
  41. package/dist/{chunk-UPKN5CYH.js → chunk-P367JZLK.js} +8 -8
  42. package/dist/{chunk-63PXZXCT.js → chunk-PPY2IRJP.js} +4 -4
  43. package/dist/{chunk-CZM2H5F5.cjs → chunk-QBXD4KOR.cjs} +5 -5
  44. package/dist/{chunk-CZM2H5F5.cjs.map → chunk-QBXD4KOR.cjs.map} +1 -1
  45. package/dist/{chunk-JGDDPNSC.cjs → chunk-QW663C35.cjs} +298 -285
  46. package/dist/chunk-QW663C35.cjs.map +1 -0
  47. package/dist/{chunk-6PEFK4UH.cjs → chunk-QX6GS6Y3.cjs} +9 -9
  48. package/dist/{chunk-6PEFK4UH.cjs.map → chunk-QX6GS6Y3.cjs.map} +1 -1
  49. package/dist/{chunk-SEVD4AST.js → chunk-RY2MDY6G.js} +7 -7
  50. package/dist/{chunk-4R6AZFNU.cjs → chunk-SAEJGTCI.cjs} +30 -30
  51. package/dist/{chunk-4R6AZFNU.cjs.map → chunk-SAEJGTCI.cjs.map} +1 -1
  52. package/dist/{chunk-TDEQ26A2.js → chunk-UAAXUFK6.js} +1 -1
  53. package/dist/chunk-UAAXUFK6.js.map +1 -0
  54. package/dist/{chunk-EO5GYWIX.cjs → chunk-V7SYWH7D.cjs} +40 -40
  55. package/dist/{chunk-EO5GYWIX.cjs.map → chunk-V7SYWH7D.cjs.map} +1 -1
  56. package/dist/{chunk-MX7DNV7X.cjs → chunk-VZMA3IUV.cjs} +33 -33
  57. package/dist/{chunk-MX7DNV7X.cjs.map → chunk-VZMA3IUV.cjs.map} +1 -1
  58. package/dist/{chunk-NNMAMTA5.js → chunk-WC3M4XJ2.js} +3 -3
  59. package/dist/{chunk-SK4IVCSI.cjs → chunk-WCBRZCU4.cjs} +1 -1
  60. package/dist/chunk-WCBRZCU4.cjs.map +1 -0
  61. package/dist/{chunk-2WLW5HDU.js → chunk-WE4OW4XP.js} +8 -8
  62. package/dist/{chunk-MDUAU74C.cjs → chunk-WKZ3JYMP.cjs} +28 -28
  63. package/dist/{chunk-MDUAU74C.cjs.map → chunk-WKZ3JYMP.cjs.map} +1 -1
  64. package/dist/components/case-studies/index.cjs +16 -16
  65. package/dist/components/case-studies/index.js +6 -6
  66. package/dist/components/chat/index.cjs +4 -4
  67. package/dist/components/chat/index.js +3 -3
  68. package/dist/components/contact/index.cjs +7 -7
  69. package/dist/components/contact/index.js +6 -6
  70. package/dist/components/docs/index.cjs +10 -10
  71. package/dist/components/docs/index.js +9 -9
  72. package/dist/components/embeds/index.cjs +7 -7
  73. package/dist/components/embeds/index.js +6 -6
  74. package/dist/components/faq/index.cjs +7 -7
  75. package/dist/components/faq/index.js +6 -6
  76. package/dist/components/features/index.cjs +6 -6
  77. package/dist/components/features/index.js +5 -5
  78. package/dist/components/help-center-pages/index.cjs +26 -26
  79. package/dist/components/help-center-pages/index.js +17 -17
  80. package/dist/components/icons/index.cjs +2 -2
  81. package/dist/components/icons/index.js +1 -1
  82. package/dist/components/icons/mlg-logo.d.ts.map +1 -1
  83. package/dist/components/index.cjs +56 -56
  84. package/dist/components/index.js +17 -17
  85. package/dist/components/meeting-scheduler/booking-form.d.ts +70 -6
  86. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  87. package/dist/components/meeting-scheduler/context-panel.d.ts +9 -1
  88. package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
  89. package/dist/components/meeting-scheduler/index.cjs +395 -188
  90. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  91. package/dist/components/meeting-scheduler/index.d.ts +55 -1
  92. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  93. package/dist/components/meeting-scheduler/index.js +381 -174
  94. package/dist/components/meeting-scheduler/index.js.map +1 -1
  95. package/dist/components/meeting-scheduler/slot-picker.d.ts +8 -1
  96. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  97. package/dist/components/navigation/index.cjs +9 -9
  98. package/dist/components/navigation/index.js +8 -8
  99. package/dist/components/onboarding-guides/index.cjs +9 -9
  100. package/dist/components/onboarding-guides/index.js +8 -8
  101. package/dist/components/related-content/index.cjs +3 -3
  102. package/dist/components/related-content/index.js +2 -2
  103. package/dist/components/tickets/index.cjs +10 -10
  104. package/dist/components/tickets/index.js +9 -9
  105. package/dist/components/ui/benefit-card.d.ts.map +1 -1
  106. package/dist/components/ui/index.cjs +6 -6
  107. package/dist/components/ui/index.js +5 -5
  108. package/dist/hooks/index.cjs +3 -3
  109. package/dist/hooks/index.js +2 -2
  110. package/dist/index.cjs +10 -10
  111. package/dist/index.js +9 -9
  112. package/dist/schemas/meeting-booking-schema.cjs +19 -4
  113. package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
  114. package/dist/schemas/meeting-booking-schema.d.ts +41 -16
  115. package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
  116. package/dist/schemas/meeting-booking-schema.js +19 -5
  117. package/dist/schemas/meeting-booking-schema.js.map +1 -1
  118. package/dist/types/index.cjs.map +1 -1
  119. package/dist/types/index.js.map +1 -1
  120. package/dist/utils/index.cjs +16 -26
  121. package/dist/utils/index.cjs.map +1 -1
  122. package/dist/utils/index.js +16 -26
  123. package/dist/utils/index.js.map +1 -1
  124. package/package.json +1 -1
  125. package/src/components/icons/mlg-logo.tsx +1 -7
  126. package/src/components/meeting-scheduler/booking-form.tsx +425 -154
  127. package/src/components/meeting-scheduler/context-panel.tsx +13 -3
  128. package/src/components/meeting-scheduler/index.tsx +317 -69
  129. package/src/components/meeting-scheduler/slot-picker.tsx +17 -1
  130. package/src/components/ui/benefit-card.tsx +30 -9
  131. package/src/schemas/meeting-booking-schema.ts +58 -4
  132. package/src/types/tmcg.ts +0 -2
  133. package/src/utils/format.ts +1 -1
  134. package/dist/chunk-5EM6NGBK.cjs.map +0 -1
  135. package/dist/chunk-A5AKGC4S.js.map +0 -1
  136. package/dist/chunk-JGDDPNSC.cjs.map +0 -1
  137. package/dist/chunk-SK4IVCSI.cjs.map +0 -1
  138. package/dist/chunk-T2JAJ7MR.js.map +0 -1
  139. package/dist/chunk-TDEQ26A2.js.map +0 -1
  140. /package/dist/{chunk-U4QRK4MX.js.map → chunk-27U3NNFR.js.map} +0 -0
  141. /package/dist/{chunk-BKMSI2AW.js.map → chunk-2KQO7OMB.js.map} +0 -0
  142. /package/dist/{chunk-A57HKYLY.js.map → chunk-2NZKBKO6.js.map} +0 -0
  143. /package/dist/{chunk-3WZLPCJ7.js.map → chunk-BDLFPDAW.js.map} +0 -0
  144. /package/dist/{chunk-QLJMXCB5.js.map → chunk-BHIJ5OGK.js.map} +0 -0
  145. /package/dist/{chunk-AQ65NLGJ.js.map → chunk-E7MTTLKC.js.map} +0 -0
  146. /package/dist/{chunk-MUNVPRT2.js.map → chunk-EVMHJB6X.js.map} +0 -0
  147. /package/dist/{chunk-MOHSA65M.js.map → chunk-EZM5DNII.js.map} +0 -0
  148. /package/dist/{chunk-7N2NJPUK.js.map → chunk-FPG4S3X4.js.map} +0 -0
  149. /package/dist/{chunk-PYEJL2UG.js.map → chunk-MOKMFLKB.js.map} +0 -0
  150. /package/dist/{chunk-ZRXJX3JK.js.map → chunk-NRGBR3SN.js.map} +0 -0
  151. /package/dist/{chunk-DYXVQ24Q.js.map → chunk-ONZCED5F.js.map} +0 -0
  152. /package/dist/{chunk-UPKN5CYH.js.map → chunk-P367JZLK.js.map} +0 -0
  153. /package/dist/{chunk-63PXZXCT.js.map → chunk-PPY2IRJP.js.map} +0 -0
  154. /package/dist/{chunk-SEVD4AST.js.map → chunk-RY2MDY6G.js.map} +0 -0
  155. /package/dist/{chunk-NNMAMTA5.js.map → chunk-WC3M4XJ2.js.map} +0 -0
  156. /package/dist/{chunk-2WLW5HDU.js.map → chunk-WE4OW4XP.js.map} +0 -0
@@ -1,15 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { zodResolver } from '@hookform/resolvers/zod';
4
- import { useMemo } from 'react';
5
- import type { Ref } from 'react';
4
+ import { Fragment, useEffect, useMemo, useState } from 'react';
5
+ import type { FormEvent, ReactNode, Ref } from 'react';
6
6
  import { useForm, Controller } from 'react-hook-form';
7
7
  import {
8
- makeBookingSchema,
8
+ makeDeferredBookingSchema,
9
9
  isSupportedFormField,
10
10
  type MeetingAvailability,
11
- type MeetingBookingPayload,
11
+ type MeetingFormField,
12
+ type BookingFormValues,
12
13
  } from '../../schemas/meeting-booking-schema';
14
+ import { cn } from '../../utils/cn';
13
15
  import {
14
16
  Button,
15
17
  FieldWrapper,
@@ -22,11 +24,56 @@ import {
22
24
  SelectContent,
23
25
  SelectItem,
24
26
  Checkbox,
27
+ CheckboxBlock,
25
28
  RadioGroup,
26
29
  RadioGroupItem,
30
+ Skeleton,
27
31
  } from '../ui';
28
32
  import { HoneypotField } from '../ui/honeypot-field';
29
33
 
34
+ /**
35
+ * One field in a host-supplied row. `name` is a built-in (`email`, `firstName`,
36
+ * `lastName`) or a HubSpot-declared question's `name`.
37
+ */
38
+ export interface BookingFieldSlot {
39
+ name: string;
40
+ /** Columns out of four at `md` and up. Defaults to an even split of the row. */
41
+ span?: 1 | 2 | 3 | 4;
42
+ }
43
+
44
+ export type BookingFieldRow = BookingFieldSlot[];
45
+
46
+ /**
47
+ * A HOST-supplied consent row — the block the waitlist form draws for its SMS
48
+ * consent, here for "I agree to the Privacy Policy and to be contacted". It is
49
+ * the host's copy and the host's link, so it is a prop, not HubSpot metadata;
50
+ * HubSpot's own `legalConsent` block (verbatim, declared on the link) renders
51
+ * alongside when the link carries one.
52
+ *
53
+ * A client-side gate, exactly like the waitlist's: Continue is refused until it
54
+ * is ticked, and the tick rides in the payload as `hostConsent` — which the
55
+ * host's book route strips as an undeclared key. It is not a HubSpot consent
56
+ * record; declare `legalConsent` on the link when one is required.
57
+ */
58
+ export interface BookingFormConsent {
59
+ label: ReactNode;
60
+ description?: ReactNode;
61
+ /** Shown under the row when Continue is pressed unticked. */
62
+ errorMessage?: string;
63
+ }
64
+
65
+ /** Static so Tailwind's scanner sees every class — a template built from a
66
+ * runtime span would compile to nothing. */
67
+ const SPAN_CLASS: Record<1 | 2 | 3 | 4, string> = {
68
+ 1: 'md:col-span-1',
69
+ 2: 'md:col-span-2',
70
+ 3: 'md:col-span-3',
71
+ 4: 'md:col-span-4',
72
+ };
73
+
74
+ const evenSpan = (count: number): 1 | 2 | 3 | 4 =>
75
+ Math.min(4, Math.max(1, Math.floor(4 / Math.max(1, count)))) as 1 | 2 | 3 | 4;
76
+
30
77
  /**
31
78
  * BookingForm — attendee details + the link's declared custom questions +
32
79
  * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +
@@ -45,10 +92,36 @@ import { HoneypotField } from '../ui/honeypot-field';
45
92
  export interface BookingFormProps {
46
93
  availability: MeetingAvailability;
47
94
  meetingId: string;
48
- startTimeMs: number;
49
- durationMs: number;
50
- /** IANA zone the confirmation/invite should render in (parent-resolved). */
51
- timezone: string;
95
+ /** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */
96
+ startTimeMs?: number;
97
+ durationMs?: number;
98
+ /** IANA zone the confirmation/invite should render in (parent-resolved).
99
+ * Null until hydration, which is why `deferSlot` relaxes it. */
100
+ timezone: string | null;
101
+ /**
102
+ * Collect-only mode (`flow="details-first"`): validate against the deferred
103
+ * schema, and hand the values up instead of POSTing. The parent re-attaches
104
+ * the authoritative slot/duration/timezone when it submits.
105
+ */
106
+ deferSlot?: boolean;
107
+ /** Repopulates the form on a remount — the back edge, or an error return. */
108
+ initialValues?: Record<string, unknown>;
109
+ /** Defaults to "Confirm Booking"; details-first says "Continue". */
110
+ submitLabel?: string;
111
+ /** Small print beside the submit (details-first sets expectations). */
112
+ footerNote?: string;
113
+ /**
114
+ * Re-arrange the fields into rows instead of the built-in order (email, the
115
+ * name pair, then each declared question full width). Reuse, not a fork: the
116
+ * SAME controls, validation, consent block and honeypot — only their grouping
117
+ * changes, so a host can match a mock without owning the machine.
118
+ *
119
+ * A slot naming nothing is skipped; a declared question no row claims is
120
+ * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
121
+ */
122
+ fieldRows?: BookingFieldRow[];
123
+ /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */
124
+ consent?: BookingFormConsent;
52
125
  isSubmitting: boolean;
53
126
  onSubmit: (payload: Record<string, unknown>) => Promise<void>;
54
127
  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
@@ -62,6 +135,12 @@ export function BookingForm({
62
135
  startTimeMs,
63
136
  durationMs,
64
137
  timezone,
138
+ deferSlot = false,
139
+ initialValues,
140
+ submitLabel,
141
+ footerNote,
142
+ fieldRows,
143
+ consent,
65
144
  isSubmitting,
66
145
  onSubmit,
67
146
  honeypotInputProps,
@@ -69,36 +148,92 @@ export function BookingForm({
69
148
  }: BookingFormProps) {
70
149
  const { formFields, legalConsent } = availability;
71
150
  const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields]);
72
- const schema = useMemo(() => makeBookingSchema(supportedFields, legalConsent), [supportedFields, legalConsent]);
151
+ // The DEFERRED schema in both flows: it is the wider of the two, and a strict
152
+ // resolver is not assignable to `Resolver<BookingFormValues>`. The strict
153
+ // schema is the server's contract — see `makeBookingSchema`'s docblock.
154
+ const schema = useMemo(
155
+ () => makeDeferredBookingSchema(supportedFields, legalConsent),
156
+ [supportedFields, legalConsent],
157
+ );
158
+
159
+ const consentDefaults = useMemo(
160
+ () =>
161
+ (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({
162
+ communicationTypeId: c.communicationTypeId,
163
+ consented: false,
164
+ })),
165
+ [legalConsent],
166
+ );
73
167
 
74
168
  const {
75
169
  register,
76
170
  control,
77
171
  handleSubmit,
172
+ setValue,
78
173
  formState: { errors },
79
- } = useForm<MeetingBookingPayload>({
174
+ } = useForm<BookingFormValues>({
80
175
  resolver: zodResolver(schema),
81
176
  defaultValues: {
82
177
  meetingId,
83
- startTimeMs,
84
- durationMs,
178
+ // Genuinely OMITTED, not present-as-undefined, when the slot is deferred.
179
+ ...(deferSlot ? {} : { startTimeMs, durationMs }),
85
180
  timezone,
86
181
  locale: typeof navigator !== 'undefined' ? navigator.language : undefined,
87
182
  firstName: '',
88
183
  lastName: '',
89
184
  email: '',
90
185
  formFields: {},
91
- legalConsentResponses: (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({
92
- communicationTypeId: c.communicationTypeId,
93
- consented: false,
94
- })),
95
- },
186
+ legalConsentResponses: consentDefaults,
187
+ // A remount (back edge, or an error return) would otherwise lose every
188
+ // answer — `defaultValues` is snapshotted once and never re-read.
189
+ ...(initialValues ?? {}),
190
+ } as BookingFormValues,
96
191
  });
97
192
 
98
- const submit = handleSubmit(async data => {
99
- await onSubmit({ ...data, meetingId, startTimeMs, durationMs, timezone, ...getSignals() });
193
+ const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;
194
+
195
+ // The host consent lives OUTSIDE react-hook-form (it is not a wire field), so
196
+ // a remount restores it from the stash the same way the fields come back.
197
+ const [consented, setConsented] = useState(initialValues?.hostConsent === true);
198
+ const [consentError, setConsentError] = useState<string | null>(null);
199
+ const consentMissing = Boolean(consent) && !consented;
200
+
201
+ // The seeded availability is refetched immediately on mount, so the link's
202
+ // consent set can change WHILE this form is open. Reconcile rather than
203
+ // remount: carry each `consented` across by id and default new ids to false.
204
+ // A wholesale reset would drop what the visitor has typed; leaving it alone
205
+ // would make a newly-declared required consent impossible to satisfy.
206
+ useEffect(() => {
207
+ setValue(
208
+ 'legalConsentResponses',
209
+ consentDefaults.map(next => ({
210
+ ...next,
211
+ consented: priorConsents?.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,
212
+ })),
213
+ { shouldDirty: false },
214
+ );
215
+ }, [consentDefaults, priorConsents, setValue]);
216
+
217
+ const submitValid = handleSubmit(async data => {
218
+ if (consentMissing) return; // the error is already on screen — see `submit`
219
+ if (deferSlot) {
220
+ // Collect-only. Signals are captured HERE, while this form and its
221
+ // honeypot are still mounted — `getSignals()` reads a detached ref once
222
+ // they unmount, which would silently disable the decoy.
223
+ await onSubmit({ ...data, meetingId, hostConsent: consented });
224
+ return;
225
+ }
226
+ await onSubmit({ ...data, meetingId, hostConsent: consented, startTimeMs, durationMs, timezone, ...getSignals() });
100
227
  });
101
228
 
229
+ // Consent is checked BEFORE the resolver runs, not inside the valid branch,
230
+ // so an unticked box and an empty field are reported together rather than
231
+ // one submit apart.
232
+ const submit = (event: FormEvent<HTMLFormElement>) => {
233
+ if (consentMissing) setConsentError(consent?.errorMessage ?? 'Please agree to continue.');
234
+ return submitValid(event);
235
+ };
236
+
102
237
  const fieldError = (name: string): string | undefined => {
103
238
  const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];
104
239
  return err?.message;
@@ -116,6 +251,161 @@ export function BookingForm({
116
251
  // smallest ODS step that houses it.
117
252
  const FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';
118
253
 
254
+ // Every field as a standalone node, so the default order below and any
255
+ // host-supplied `fieldRows` compose the SAME controls. Extracting them is what
256
+ // makes an injected layout a re-arrangement rather than a fork of this form.
257
+ const builtInFields: Record<string, ReactNode> = {
258
+ email: (
259
+ <FieldWrapper label="Email" htmlFor="ms-email" error={errors.email?.message}>
260
+ <Input
261
+ id="ms-email"
262
+ type="email"
263
+ required
264
+ autoComplete="email"
265
+ placeholder="jane@company.com"
266
+ aria-invalid={!!errors.email}
267
+ className={inputClass}
268
+ {...register('email')}
269
+ />
270
+ </FieldWrapper>
271
+ ),
272
+ firstName: (
273
+ <FieldWrapper label="First Name" htmlFor="ms-first-name" error={errors.firstName?.message}>
274
+ <Input
275
+ id="ms-first-name"
276
+ required
277
+ autoComplete="given-name"
278
+ placeholder="Jane"
279
+ aria-invalid={!!errors.firstName}
280
+ className={inputClass}
281
+ {...register('firstName')}
282
+ />
283
+ </FieldWrapper>
284
+ ),
285
+ lastName: (
286
+ <FieldWrapper label="Last Name" htmlFor="ms-last-name" error={errors.lastName?.message}>
287
+ <Input
288
+ id="ms-last-name"
289
+ required
290
+ autoComplete="family-name"
291
+ placeholder="Doe"
292
+ aria-invalid={!!errors.lastName}
293
+ className={inputClass}
294
+ {...register('lastName')}
295
+ />
296
+ </FieldWrapper>
297
+ ),
298
+ };
299
+
300
+ const renderDeclaredField = (field: MeetingFormField): ReactNode => (
301
+ <FieldWrapper key={field.name} label={field.label} htmlFor={`ms-q-${field.name}`} error={fieldError(field.name)}>
302
+ {field.type === 'textarea' && (
303
+ <Textarea
304
+ id={`ms-q-${field.name}`}
305
+ className="border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary"
306
+ {...register(`formFields.${field.name}` as never)}
307
+ />
308
+ )}
309
+ {field.type === 'text' && (
310
+ <Input
311
+ id={`ms-q-${field.name}`}
312
+ className="h-11 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary md:h-12"
313
+ {...register(`formFields.${field.name}` as never)}
314
+ />
315
+ )}
316
+ {(field.type === 'select' || field.type === 'radio') && (
317
+ <Controller
318
+ control={control}
319
+ name={`formFields.${field.name}` as never}
320
+ render={({ field: rhf }) =>
321
+ field.type === 'select' ? (
322
+ <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
323
+ <SelectTrigger id={`ms-q-${field.name}`}>
324
+ <SelectValue placeholder="Select…" />
325
+ </SelectTrigger>
326
+ <SelectContent>
327
+ {(field.options ?? []).map(opt => (
328
+ <SelectItem key={opt} value={opt}>
329
+ {opt}
330
+ </SelectItem>
331
+ ))}
332
+ </SelectContent>
333
+ </Select>
334
+ ) : (
335
+ <RadioGroup value={rhf.value ?? ''} onValueChange={rhf.onChange}>
336
+ {(field.options ?? []).map(opt => (
337
+ <div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
338
+ <RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />
339
+ <Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>
340
+ </div>
341
+ ))}
342
+ </RadioGroup>
343
+ )
344
+ }
345
+ />
346
+ )}
347
+ {field.type === 'checkbox' && (
348
+ <Controller
349
+ control={control}
350
+ name={`formFields.${field.name}` as never}
351
+ render={({ field: rhf }) => (
352
+ // The question's Label above already carries the text and
353
+ // binds to this id — a second inline label would double the
354
+ // visible text AND the accessible name.
355
+ <div className="flex items-center gap-[var(--spacing-system-xs)]">
356
+ <Checkbox
357
+ id={`ms-q-${field.name}`}
358
+ checked={Boolean(rhf.value)}
359
+ onCheckedChange={v => rhf.onChange(v === true)}
360
+ />
361
+ </div>
362
+ )}
363
+ />
364
+ )}
365
+ </FieldWrapper>
366
+ );
367
+
368
+ const declaredByName = new Map(supportedFields.map(f => [f.name, f]));
369
+
370
+ const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));
371
+
372
+ const slotNode = (name: string): ReactNode => {
373
+ const builtIn = builtInFields[name];
374
+ if (builtIn) return builtIn;
375
+ const declared = declaredByName.get(name);
376
+ // A name matching nothing is SKIPPED, not an error: a row may reference a
377
+ // question the link has not declared yet (declaring it in HubSpot is what
378
+ // turns it on), and throwing would take the whole form down over config.
379
+ return declared ? renderDeclaredField(declared) : null;
380
+ };
381
+
382
+ /** Declared questions no row claims — appended full width, so a question added
383
+ * in HubSpot can never go invisible by omission from a layout written before it. */
384
+ const unplacedFields = fieldRows
385
+ ? supportedFields.filter(f => !fieldRows.some(row => row.some(slot => slot.name === f.name)))
386
+ : [];
387
+
388
+ /** Rows whose every slot names a question the link has not declared are
389
+ * DROPPED, not rendered empty: an empty grid still eats one form gap, so a
390
+ * layout written ahead of the HubSpot config would print blank bands. */
391
+ const placedRows = (fieldRows ?? [])
392
+ .map(row => row.filter(slot => slotResolves(slot.name)))
393
+ .filter(row => row.length > 0);
394
+
395
+ const submitButton = (
396
+ <Button
397
+ type="submit"
398
+ loading={isSubmitting}
399
+ disabled={isSubmitting}
400
+ // The details-first footer draws a 240px action beside its note
401
+ // (`4904:117335`); the bare slot-first row keeps the button at its natural
402
+ // width, as it always has.
403
+ className={footerNote ? 'md:w-60' : undefined}
404
+ >
405
+ {submitLabel ?? 'Confirm Booking'}
406
+ </Button>
407
+ );
408
+
119
409
  return (
120
410
  <form onSubmit={submit} className={FORM_STACK} noValidate>
121
411
  <HoneypotField {...honeypotInputProps} />
@@ -132,116 +422,61 @@ export function BookingForm({
132
422
  submit button off the bottom the moment validation fails. Required-ness
133
423
  is carried by `required` on the control (read out by assistive tech),
134
424
  not by an asterisk the design does not draw. */}
135
- <FieldWrapper label="Email" htmlFor="ms-email" error={errors.email?.message}>
136
- <Input
137
- id="ms-email"
138
- type="email"
139
- required
140
- autoComplete="email"
141
- placeholder="jane@company.com"
142
- aria-invalid={!!errors.email}
143
- className={inputClass}
144
- {...register('email')}
145
- />
146
- </FieldWrapper>
147
-
148
- <div className="grid grid-cols-2 gap-[var(--spacing-system-m)]">
149
- <FieldWrapper label="First Name" htmlFor="ms-first-name" error={errors.firstName?.message}>
150
- <Input
151
- id="ms-first-name"
152
- required
153
- autoComplete="given-name"
154
- placeholder="Jane"
155
- aria-invalid={!!errors.firstName}
156
- className={inputClass}
157
- {...register('firstName')}
158
- />
159
- </FieldWrapper>
160
- <FieldWrapper label="Last Name" htmlFor="ms-last-name" error={errors.lastName?.message}>
161
- <Input
162
- id="ms-last-name"
163
- required
164
- autoComplete="family-name"
165
- placeholder="Doe"
166
- aria-invalid={!!errors.lastName}
167
- className={inputClass}
168
- {...register('lastName')}
169
- />
170
- </FieldWrapper>
171
- </div>
172
-
173
- {supportedFields.map(field => (
174
- <FieldWrapper
175
- key={field.name}
176
- label={field.label}
177
- htmlFor={`ms-q-${field.name}`}
178
- error={fieldError(field.name)}
179
- >
180
- {field.type === 'textarea' && (
181
- <Textarea
182
- id={`ms-q-${field.name}`}
183
- className="border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary"
184
- {...register(`formFields.${field.name}` as never)}
185
- />
186
- )}
187
- {field.type === 'text' && (
188
- <Input
189
- id={`ms-q-${field.name}`}
190
- className="h-11 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary md:h-12"
191
- {...register(`formFields.${field.name}` as never)}
192
- />
193
- )}
194
- {(field.type === 'select' || field.type === 'radio') && (
195
- <Controller
196
- control={control}
197
- name={`formFields.${field.name}` as never}
198
- render={({ field: rhf }) =>
199
- field.type === 'select' ? (
200
- <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
201
- <SelectTrigger id={`ms-q-${field.name}`}>
202
- <SelectValue placeholder="Select…" />
203
- </SelectTrigger>
204
- <SelectContent>
205
- {(field.options ?? []).map(opt => (
206
- <SelectItem key={opt} value={opt}>
207
- {opt}
208
- </SelectItem>
209
- ))}
210
- </SelectContent>
211
- </Select>
212
- ) : (
213
- <RadioGroup value={rhf.value ?? ''} onValueChange={rhf.onChange}>
214
- {(field.options ?? []).map(opt => (
215
- <div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
216
- <RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />
217
- <Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>
218
- </div>
219
- ))}
220
- </RadioGroup>
221
- )
222
- }
223
- />
224
- )}
225
- {field.type === 'checkbox' && (
226
- <Controller
227
- control={control}
228
- name={`formFields.${field.name}` as never}
229
- render={({ field: rhf }) => (
230
- // The question's Label above already carries the text and
231
- // binds to this id — a second inline label would double the
232
- // visible text AND the accessible name.
233
- <div className="flex items-center gap-[var(--spacing-system-xs)]">
234
- <Checkbox
235
- id={`ms-q-${field.name}`}
236
- checked={Boolean(rhf.value)}
237
- onCheckedChange={v => rhf.onChange(v === true)}
238
- />
425
+ {fieldRows ? (
426
+ <>
427
+ {placedRows.map((row, rowIndex) => (
428
+ <div
429
+ key={row.map(s => s.name).join('|') || rowIndex}
430
+ className="grid grid-cols-2 gap-[var(--spacing-system-m)] md:grid-cols-4"
431
+ >
432
+ {row.map(slot => (
433
+ <div
434
+ key={slot.name}
435
+ className={cn(
436
+ // A two-field row stays side by side on a phone — the rule
437
+ // the built-in name pair has always followed: two short
438
+ // fields cost one line instead of two on the layout that can
439
+ // least afford them.
440
+ row.length === 2 ? 'col-span-1' : 'col-span-2',
441
+ SPAN_CLASS[slot.span ?? evenSpan(row.length)],
442
+ )}
443
+ >
444
+ {slotNode(slot.name)}
239
445
  </div>
240
- )}
241
- />
242
- )}
243
- </FieldWrapper>
244
- ))}
446
+ ))}
447
+ </div>
448
+ ))}
449
+ {unplacedFields.map(field => (
450
+ <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
451
+ ))}
452
+ </>
453
+ ) : (
454
+ <>
455
+ {builtInFields.email}
456
+ <div className="grid grid-cols-2 gap-[var(--spacing-system-m)]">
457
+ {builtInFields.firstName}
458
+ {builtInFields.lastName}
459
+ </div>
460
+ {supportedFields.map(field => (
461
+ <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
462
+ ))}
463
+ </>
464
+ )}
465
+
466
+ {consent && (
467
+ <CheckboxBlock
468
+ id="ms-host-consent"
469
+ checked={consented}
470
+ onCheckedChange={v => {
471
+ setConsented(v);
472
+ if (v) setConsentError(null);
473
+ }}
474
+ disabled={isSubmitting}
475
+ error={consentError ?? undefined}
476
+ label={consent.label}
477
+ description={consent.description}
478
+ />
479
+ )}
245
480
 
246
481
  {legalConsent && (
247
482
  // Same out-of-flow message as the fields above, so a missed consent
@@ -249,40 +484,44 @@ export function BookingForm({
249
484
  // wrapper is `display:contents` and the panel below is the flex item,
250
485
  // exactly as it was.
251
486
  <FieldWrapper error={errors.legalConsentResponses?.message}>
252
- <div className="flex flex-col gap-[var(--spacing-system-xs)] rounded-md border border-ods-border p-[var(--spacing-system-m)]">
253
- {/* GDPR surface — HubSpot's consent copy rendered VERBATIM, never edited. */}
254
- <p className="text-ods-text-secondary text-h6">{legalConsent.processingConsentText}</p>
255
- {legalConsent.communicationConsentText && (
256
- <p className="text-ods-text-secondary text-h6">{legalConsent.communicationConsentText}</p>
257
- )}
487
+ <div className="flex flex-col gap-[var(--spacing-system-xs)]">
258
488
  <Controller
259
489
  control={control}
260
490
  name="legalConsentResponses"
261
491
  render={({ field: rhf }) => (
262
492
  <>
263
- {legalConsent.communicationConsentCheckboxes.map(box => {
493
+ {legalConsent.communicationConsentCheckboxes.map((box, index) => {
264
494
  const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;
265
495
  const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);
266
496
  return (
267
- <div key={box.communicationTypeId} className="flex items-center gap-[var(--spacing-system-xs)]">
268
- <Checkbox
269
- id={`ms-consent-${box.communicationTypeId}`}
270
- checked={current?.consented ?? false}
271
- onCheckedChange={v =>
272
- rhf.onChange(
273
- responses.map(r =>
274
- r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v === true } : r,
275
- ),
276
- )
277
- }
278
- />
279
- <Label htmlFor={`ms-consent-${box.communicationTypeId}`}>{box.label}</Label>
280
- </div>
497
+ // The design system's consent row (`checkbox-block`, the
498
+ // same block the waitlist form uses): box, label, caption
499
+ // in one bordered row. GDPR surface — HubSpot's copy is
500
+ // rendered VERBATIM, never edited. The processing statement
501
+ // rides as the caption of the FIRST row, once: it describes
502
+ // the consent as a whole, not each channel.
503
+ <CheckboxBlock
504
+ key={box.communicationTypeId}
505
+ id={`ms-consent-${box.communicationTypeId}`}
506
+ checked={current?.consented ?? false}
507
+ onCheckedChange={v =>
508
+ rhf.onChange(
509
+ responses.map(r =>
510
+ r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,
511
+ ),
512
+ )
513
+ }
514
+ label={box.label}
515
+ description={index === 0 ? legalConsent.processingConsentText : undefined}
516
+ />
281
517
  );
282
518
  })}
283
519
  </>
284
520
  )}
285
521
  />
522
+ {legalConsent.communicationConsentText && (
523
+ <p className="text-ods-text-secondary text-h6">{legalConsent.communicationConsentText}</p>
524
+ )}
286
525
  {legalConsent.privacyPolicyText && (
287
526
  <p className="text-ods-text-secondary text-h6">{legalConsent.privacyPolicyText}</p>
288
527
  )}
@@ -293,11 +532,43 @@ export function BookingForm({
293
532
  {/* Step navigation back to the calendar lives in the step header (the
294
533
  app-standard BackButton, rendered by the parent) — the form ships
295
534
  only its submit. */}
296
- <div className="flex">
297
- <Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
298
- Confirm Booking
299
- </Button>
300
- </div>
535
+ {/* Two shapes, not one with a placeholder: without a note this must stay
536
+ the bare `flex` row it has always been, or the submit slides from the
537
+ left edge to the right on every existing slot-first booking. */}
538
+ {footerNote ? (
539
+ <div className="flex items-center justify-between gap-[var(--spacing-system-m)]">
540
+ <p className="text-ods-text-secondary text-h6">{footerNote}</p>
541
+ {submitButton}
542
+ </div>
543
+ ) : (
544
+ <div className="flex">{submitButton}</div>
545
+ )}
301
546
  </form>
302
547
  );
303
548
  }
549
+
550
+ /**
551
+ * Cold-start placeholder for `flow="details-first"`, where the FORM is the
552
+ * first thing in the action panel.
553
+ *
554
+ * The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar
555
+ * where the form belongs — above the fold on a page whose entire content is
556
+ * this card. Same footprint discipline as its sibling: fixed heights, no shift
557
+ * when the real form swaps in.
558
+ */
559
+ export function BookingFormSkeleton() {
560
+ return (
561
+ <div className="flex flex-1 flex-col gap-[var(--spacing-system-l)] p-[var(--spacing-system-l)] lg:p-0">
562
+ <div className="grid grid-cols-1 gap-[var(--spacing-system-m)] md:grid-cols-2">
563
+ <Skeleton className="h-[4.75rem] w-full" />
564
+ <Skeleton className="h-[4.75rem] w-full" />
565
+ </div>
566
+ <Skeleton className="h-[4.75rem] w-full" />
567
+ <Skeleton className="h-[7.75rem] w-full" />
568
+ <Skeleton className="h-[4.25rem] w-full" />
569
+ <div className="flex justify-end">
570
+ <Skeleton className="h-12 w-40" />
571
+ </div>
572
+ </div>
573
+ );
574
+ }