@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
@@ -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 } from 'react';
5
+ import type { 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,37 @@ 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
+ /** Static so Tailwind's scanner sees every class — a template built from a
47
+ * runtime span would compile to nothing. */
48
+ const SPAN_CLASS: Record<1 | 2 | 3 | 4, string> = {
49
+ 1: 'md:col-span-1',
50
+ 2: 'md:col-span-2',
51
+ 3: 'md:col-span-3',
52
+ 4: 'md:col-span-4',
53
+ };
54
+
55
+ const evenSpan = (count: number): 1 | 2 | 3 | 4 =>
56
+ Math.min(4, Math.max(1, Math.floor(4 / Math.max(1, count)))) as 1 | 2 | 3 | 4;
57
+
30
58
  /**
31
59
  * BookingForm — attendee details + the link's declared custom questions +
32
60
  * verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +
@@ -45,10 +73,34 @@ import { HoneypotField } from '../ui/honeypot-field';
45
73
  export interface BookingFormProps {
46
74
  availability: MeetingAvailability;
47
75
  meetingId: string;
48
- startTimeMs: number;
49
- durationMs: number;
50
- /** IANA zone the confirmation/invite should render in (parent-resolved). */
51
- timezone: string;
76
+ /** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */
77
+ startTimeMs?: number;
78
+ durationMs?: number;
79
+ /** IANA zone the confirmation/invite should render in (parent-resolved).
80
+ * Null until hydration, which is why `deferSlot` relaxes it. */
81
+ timezone: string | null;
82
+ /**
83
+ * Collect-only mode (`flow="details-first"`): validate against the deferred
84
+ * schema, and hand the values up instead of POSTing. The parent re-attaches
85
+ * the authoritative slot/duration/timezone when it submits.
86
+ */
87
+ deferSlot?: boolean;
88
+ /** Repopulates the form on a remount — the back edge, or an error return. */
89
+ initialValues?: Record<string, unknown>;
90
+ /** Defaults to "Confirm Booking"; details-first says "Continue". */
91
+ submitLabel?: string;
92
+ /** Small print beside the submit (details-first sets expectations). */
93
+ footerNote?: string;
94
+ /**
95
+ * Re-arrange the fields into rows instead of the built-in order (email, the
96
+ * name pair, then each declared question full width). Reuse, not a fork: the
97
+ * SAME controls, validation, consent block and honeypot — only their grouping
98
+ * changes, so a host can match a mock without owning the machine.
99
+ *
100
+ * A slot naming nothing is skipped; a declared question no row claims is
101
+ * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
102
+ */
103
+ fieldRows?: BookingFieldRow[];
52
104
  isSubmitting: boolean;
53
105
  onSubmit: (payload: Record<string, unknown>) => Promise<void>;
54
106
  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
@@ -62,6 +114,11 @@ export function BookingForm({
62
114
  startTimeMs,
63
115
  durationMs,
64
116
  timezone,
117
+ deferSlot = false,
118
+ initialValues,
119
+ submitLabel,
120
+ footerNote,
121
+ fieldRows,
65
122
  isSubmitting,
66
123
  onSubmit,
67
124
  honeypotInputProps,
@@ -69,33 +126,74 @@ export function BookingForm({
69
126
  }: BookingFormProps) {
70
127
  const { formFields, legalConsent } = availability;
71
128
  const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields]);
72
- const schema = useMemo(() => makeBookingSchema(supportedFields, legalConsent), [supportedFields, legalConsent]);
129
+ // The DEFERRED schema in both flows: it is the wider of the two, and a strict
130
+ // resolver is not assignable to `Resolver<BookingFormValues>`. The strict
131
+ // schema is the server's contract — see `makeBookingSchema`'s docblock.
132
+ const schema = useMemo(
133
+ () => makeDeferredBookingSchema(supportedFields, legalConsent),
134
+ [supportedFields, legalConsent],
135
+ );
136
+
137
+ const consentDefaults = useMemo(
138
+ () =>
139
+ (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({
140
+ communicationTypeId: c.communicationTypeId,
141
+ consented: false,
142
+ })),
143
+ [legalConsent],
144
+ );
73
145
 
74
146
  const {
75
147
  register,
76
148
  control,
77
149
  handleSubmit,
150
+ setValue,
78
151
  formState: { errors },
79
- } = useForm<MeetingBookingPayload>({
152
+ } = useForm<BookingFormValues>({
80
153
  resolver: zodResolver(schema),
81
154
  defaultValues: {
82
155
  meetingId,
83
- startTimeMs,
84
- durationMs,
156
+ // Genuinely OMITTED, not present-as-undefined, when the slot is deferred.
157
+ ...(deferSlot ? {} : { startTimeMs, durationMs }),
85
158
  timezone,
86
159
  locale: typeof navigator !== 'undefined' ? navigator.language : undefined,
87
160
  firstName: '',
88
161
  lastName: '',
89
162
  email: '',
90
163
  formFields: {},
91
- legalConsentResponses: (legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({
92
- communicationTypeId: c.communicationTypeId,
93
- consented: false,
94
- })),
95
- },
164
+ legalConsentResponses: consentDefaults,
165
+ // A remount (back edge, or an error return) would otherwise lose every
166
+ // answer — `defaultValues` is snapshotted once and never re-read.
167
+ ...(initialValues ?? {}),
168
+ } as BookingFormValues,
96
169
  });
97
170
 
171
+ const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;
172
+
173
+ // The seeded availability is refetched immediately on mount, so the link's
174
+ // consent set can change WHILE this form is open. Reconcile rather than
175
+ // remount: carry each `consented` across by id and default new ids to false.
176
+ // A wholesale reset would drop what the visitor has typed; leaving it alone
177
+ // would make a newly-declared required consent impossible to satisfy.
178
+ useEffect(() => {
179
+ setValue(
180
+ 'legalConsentResponses',
181
+ consentDefaults.map(next => ({
182
+ ...next,
183
+ consented: priorConsents?.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,
184
+ })),
185
+ { shouldDirty: false },
186
+ );
187
+ }, [consentDefaults, priorConsents, setValue]);
188
+
98
189
  const submit = handleSubmit(async data => {
190
+ if (deferSlot) {
191
+ // Collect-only. Signals are captured HERE, while this form and its
192
+ // honeypot are still mounted — `getSignals()` reads a detached ref once
193
+ // they unmount, which would silently disable the decoy.
194
+ await onSubmit({ ...data, meetingId });
195
+ return;
196
+ }
99
197
  await onSubmit({ ...data, meetingId, startTimeMs, durationMs, timezone, ...getSignals() });
100
198
  });
101
199
 
@@ -116,6 +214,161 @@ export function BookingForm({
116
214
  // smallest ODS step that houses it.
117
215
  const FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';
118
216
 
217
+ // Every field as a standalone node, so the default order below and any
218
+ // host-supplied `fieldRows` compose the SAME controls. Extracting them is what
219
+ // makes an injected layout a re-arrangement rather than a fork of this form.
220
+ const builtInFields: Record<string, ReactNode> = {
221
+ email: (
222
+ <FieldWrapper label="Email" htmlFor="ms-email" error={errors.email?.message}>
223
+ <Input
224
+ id="ms-email"
225
+ type="email"
226
+ required
227
+ autoComplete="email"
228
+ placeholder="jane@company.com"
229
+ aria-invalid={!!errors.email}
230
+ className={inputClass}
231
+ {...register('email')}
232
+ />
233
+ </FieldWrapper>
234
+ ),
235
+ firstName: (
236
+ <FieldWrapper label="First Name" htmlFor="ms-first-name" error={errors.firstName?.message}>
237
+ <Input
238
+ id="ms-first-name"
239
+ required
240
+ autoComplete="given-name"
241
+ placeholder="Jane"
242
+ aria-invalid={!!errors.firstName}
243
+ className={inputClass}
244
+ {...register('firstName')}
245
+ />
246
+ </FieldWrapper>
247
+ ),
248
+ lastName: (
249
+ <FieldWrapper label="Last Name" htmlFor="ms-last-name" error={errors.lastName?.message}>
250
+ <Input
251
+ id="ms-last-name"
252
+ required
253
+ autoComplete="family-name"
254
+ placeholder="Doe"
255
+ aria-invalid={!!errors.lastName}
256
+ className={inputClass}
257
+ {...register('lastName')}
258
+ />
259
+ </FieldWrapper>
260
+ ),
261
+ };
262
+
263
+ const renderDeclaredField = (field: MeetingFormField): ReactNode => (
264
+ <FieldWrapper key={field.name} label={field.label} htmlFor={`ms-q-${field.name}`} error={fieldError(field.name)}>
265
+ {field.type === 'textarea' && (
266
+ <Textarea
267
+ id={`ms-q-${field.name}`}
268
+ className="border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary"
269
+ {...register(`formFields.${field.name}` as never)}
270
+ />
271
+ )}
272
+ {field.type === 'text' && (
273
+ <Input
274
+ id={`ms-q-${field.name}`}
275
+ className="h-11 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary md:h-12"
276
+ {...register(`formFields.${field.name}` as never)}
277
+ />
278
+ )}
279
+ {(field.type === 'select' || field.type === 'radio') && (
280
+ <Controller
281
+ control={control}
282
+ name={`formFields.${field.name}` as never}
283
+ render={({ field: rhf }) =>
284
+ field.type === 'select' ? (
285
+ <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
286
+ <SelectTrigger id={`ms-q-${field.name}`}>
287
+ <SelectValue placeholder="Select…" />
288
+ </SelectTrigger>
289
+ <SelectContent>
290
+ {(field.options ?? []).map(opt => (
291
+ <SelectItem key={opt} value={opt}>
292
+ {opt}
293
+ </SelectItem>
294
+ ))}
295
+ </SelectContent>
296
+ </Select>
297
+ ) : (
298
+ <RadioGroup value={rhf.value ?? ''} onValueChange={rhf.onChange}>
299
+ {(field.options ?? []).map(opt => (
300
+ <div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
301
+ <RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />
302
+ <Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>
303
+ </div>
304
+ ))}
305
+ </RadioGroup>
306
+ )
307
+ }
308
+ />
309
+ )}
310
+ {field.type === 'checkbox' && (
311
+ <Controller
312
+ control={control}
313
+ name={`formFields.${field.name}` as never}
314
+ render={({ field: rhf }) => (
315
+ // The question's Label above already carries the text and
316
+ // binds to this id — a second inline label would double the
317
+ // visible text AND the accessible name.
318
+ <div className="flex items-center gap-[var(--spacing-system-xs)]">
319
+ <Checkbox
320
+ id={`ms-q-${field.name}`}
321
+ checked={Boolean(rhf.value)}
322
+ onCheckedChange={v => rhf.onChange(v === true)}
323
+ />
324
+ </div>
325
+ )}
326
+ />
327
+ )}
328
+ </FieldWrapper>
329
+ );
330
+
331
+ const declaredByName = new Map(supportedFields.map(f => [f.name, f]));
332
+
333
+ const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));
334
+
335
+ const slotNode = (name: string): ReactNode => {
336
+ const builtIn = builtInFields[name];
337
+ if (builtIn) return builtIn;
338
+ const declared = declaredByName.get(name);
339
+ // A name matching nothing is SKIPPED, not an error: a row may reference a
340
+ // question the link has not declared yet (declaring it in HubSpot is what
341
+ // turns it on), and throwing would take the whole form down over config.
342
+ return declared ? renderDeclaredField(declared) : null;
343
+ };
344
+
345
+ /** Declared questions no row claims — appended full width, so a question added
346
+ * in HubSpot can never go invisible by omission from a layout written before it. */
347
+ const unplacedFields = fieldRows
348
+ ? supportedFields.filter(f => !fieldRows.some(row => row.some(slot => slot.name === f.name)))
349
+ : [];
350
+
351
+ /** Rows whose every slot names a question the link has not declared are
352
+ * DROPPED, not rendered empty: an empty grid still eats one form gap, so a
353
+ * layout written ahead of the HubSpot config would print blank bands. */
354
+ const placedRows = (fieldRows ?? [])
355
+ .map(row => row.filter(slot => slotResolves(slot.name)))
356
+ .filter(row => row.length > 0);
357
+
358
+ const submitButton = (
359
+ <Button
360
+ type="submit"
361
+ loading={isSubmitting}
362
+ disabled={isSubmitting}
363
+ // The details-first footer draws a 240px action beside its note
364
+ // (`4904:117335`); the bare slot-first row keeps the button at its natural
365
+ // width, as it always has.
366
+ className={footerNote ? 'md:w-60' : undefined}
367
+ >
368
+ {submitLabel ?? 'Confirm Booking'}
369
+ </Button>
370
+ );
371
+
119
372
  return (
120
373
  <form onSubmit={submit} className={FORM_STACK} noValidate>
121
374
  <HoneypotField {...honeypotInputProps} />
@@ -132,116 +385,46 @@ export function BookingForm({
132
385
  submit button off the bottom the moment validation fails. Required-ness
133
386
  is carried by `required` on the control (read out by assistive tech),
134
387
  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
- />
388
+ {fieldRows ? (
389
+ <>
390
+ {placedRows.map((row, rowIndex) => (
391
+ <div
392
+ key={row.map(s => s.name).join('|') || rowIndex}
393
+ className="grid grid-cols-2 gap-[var(--spacing-system-m)] md:grid-cols-4"
394
+ >
395
+ {row.map(slot => (
396
+ <div
397
+ key={slot.name}
398
+ className={cn(
399
+ // A two-field row stays side by side on a phone — the rule
400
+ // the built-in name pair has always followed: two short
401
+ // fields cost one line instead of two on the layout that can
402
+ // least afford them.
403
+ row.length === 2 ? 'col-span-1' : 'col-span-2',
404
+ SPAN_CLASS[slot.span ?? evenSpan(row.length)],
405
+ )}
406
+ >
407
+ {slotNode(slot.name)}
239
408
  </div>
240
- )}
241
- />
242
- )}
243
- </FieldWrapper>
244
- ))}
409
+ ))}
410
+ </div>
411
+ ))}
412
+ {unplacedFields.map(field => (
413
+ <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
414
+ ))}
415
+ </>
416
+ ) : (
417
+ <>
418
+ {builtInFields.email}
419
+ <div className="grid grid-cols-2 gap-[var(--spacing-system-m)]">
420
+ {builtInFields.firstName}
421
+ {builtInFields.lastName}
422
+ </div>
423
+ {supportedFields.map(field => (
424
+ <Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
425
+ ))}
426
+ </>
427
+ )}
245
428
 
246
429
  {legalConsent && (
247
430
  // Same out-of-flow message as the fields above, so a missed consent
@@ -249,40 +432,44 @@ export function BookingForm({
249
432
  // wrapper is `display:contents` and the panel below is the flex item,
250
433
  // exactly as it was.
251
434
  <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
- )}
435
+ <div className="flex flex-col gap-[var(--spacing-system-xs)]">
258
436
  <Controller
259
437
  control={control}
260
438
  name="legalConsentResponses"
261
439
  render={({ field: rhf }) => (
262
440
  <>
263
- {legalConsent.communicationConsentCheckboxes.map(box => {
441
+ {legalConsent.communicationConsentCheckboxes.map((box, index) => {
264
442
  const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;
265
443
  const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);
266
444
  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>
445
+ // The design system's consent row (`checkbox-block`, the
446
+ // same block the waitlist form uses): box, label, caption
447
+ // in one bordered row. GDPR surface — HubSpot's copy is
448
+ // rendered VERBATIM, never edited. The processing statement
449
+ // rides as the caption of the FIRST row, once: it describes
450
+ // the consent as a whole, not each channel.
451
+ <CheckboxBlock
452
+ key={box.communicationTypeId}
453
+ id={`ms-consent-${box.communicationTypeId}`}
454
+ checked={current?.consented ?? false}
455
+ onCheckedChange={v =>
456
+ rhf.onChange(
457
+ responses.map(r =>
458
+ r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,
459
+ ),
460
+ )
461
+ }
462
+ label={box.label}
463
+ description={index === 0 ? legalConsent.processingConsentText : undefined}
464
+ />
281
465
  );
282
466
  })}
283
467
  </>
284
468
  )}
285
469
  />
470
+ {legalConsent.communicationConsentText && (
471
+ <p className="text-ods-text-secondary text-h6">{legalConsent.communicationConsentText}</p>
472
+ )}
286
473
  {legalConsent.privacyPolicyText && (
287
474
  <p className="text-ods-text-secondary text-h6">{legalConsent.privacyPolicyText}</p>
288
475
  )}
@@ -293,11 +480,43 @@ export function BookingForm({
293
480
  {/* Step navigation back to the calendar lives in the step header (the
294
481
  app-standard BackButton, rendered by the parent) — the form ships
295
482
  only its submit. */}
296
- <div className="flex">
297
- <Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
298
- Confirm Booking
299
- </Button>
300
- </div>
483
+ {/* Two shapes, not one with a placeholder: without a note this must stay
484
+ the bare `flex` row it has always been, or the submit slides from the
485
+ left edge to the right on every existing slot-first booking. */}
486
+ {footerNote ? (
487
+ <div className="flex items-center justify-between gap-[var(--spacing-system-m)]">
488
+ <p className="text-ods-text-secondary text-h6">{footerNote}</p>
489
+ {submitButton}
490
+ </div>
491
+ ) : (
492
+ <div className="flex">{submitButton}</div>
493
+ )}
301
494
  </form>
302
495
  );
303
496
  }
497
+
498
+ /**
499
+ * Cold-start placeholder for `flow="details-first"`, where the FORM is the
500
+ * first thing in the action panel.
501
+ *
502
+ * The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar
503
+ * where the form belongs — above the fold on a page whose entire content is
504
+ * this card. Same footprint discipline as its sibling: fixed heights, no shift
505
+ * when the real form swaps in.
506
+ */
507
+ export function BookingFormSkeleton() {
508
+ return (
509
+ <div className="flex flex-1 flex-col gap-[var(--spacing-system-l)] p-[var(--spacing-system-l)] lg:p-0">
510
+ <div className="grid grid-cols-1 gap-[var(--spacing-system-m)] md:grid-cols-2">
511
+ <Skeleton className="h-[4.75rem] w-full" />
512
+ <Skeleton className="h-[4.75rem] w-full" />
513
+ </div>
514
+ <Skeleton className="h-[4.75rem] w-full" />
515
+ <Skeleton className="h-[7.75rem] w-full" />
516
+ <Skeleton className="h-[4.25rem] w-full" />
517
+ <div className="flex justify-end">
518
+ <Skeleton className="h-12 w-40" />
519
+ </div>
520
+ </div>
521
+ );
522
+ }
@@ -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)]">