@flamingo-stack/openframe-frontend-core 0.0.664 → 0.0.665-2372.7066.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.
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +177 -35
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +151 -9
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/schemas/meeting-booking-schema.cjs +107 -6
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +105 -19
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +99 -7
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/meeting-booking-schema.test.ts +2 -2
- package/src/__tests__/meeting-form-field-types.test.tsx +240 -0
- package/src/__tests__/meeting-scheduler-details-first.test.tsx +33 -0
- package/src/components/meeting-scheduler/booking-form.tsx +67 -4
- package/src/components/meeting-scheduler/index.tsx +9 -6
- package/src/schemas/meeting-booking-schema.ts +231 -25
- package/src/utils/__tests__/hubspot-meetings-convention.test.ts +15 -6
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
DECIMAL_LITERAL_RE,
|
|
12
12
|
fieldTypeSpec,
|
|
13
13
|
makeDeferredBookingSchema,
|
|
14
|
-
|
|
14
|
+
MULTI_VALUE_SEPARATOR,
|
|
15
|
+
normalizeFormFields,
|
|
16
|
+
splitMultiValue,
|
|
15
17
|
type BuiltInBookingField,
|
|
16
18
|
type MeetingAvailability,
|
|
17
19
|
type SupportedFormFieldType,
|
|
@@ -151,6 +153,12 @@ interface ControlArgs {
|
|
|
151
153
|
const placeholderFor = (field: ControlArgs['field']): string | undefined =>
|
|
152
154
|
field.placeholder ?? fieldTypeSpec(field.type).placeholder?.(field);
|
|
153
155
|
|
|
156
|
+
/** An option's display text: HubSpot's label when it differs from the submitted value. */
|
|
157
|
+
const optionLabel = (field: ControlArgs['field'], value: string): string => field.optionLabels?.[value] ?? value;
|
|
158
|
+
|
|
159
|
+
/** A free-text answer is sent without the whitespace a paste drags in. */
|
|
160
|
+
const trimmed = (v: unknown): string => String(v ?? '').trim();
|
|
161
|
+
|
|
154
162
|
/** `<input type="number">` accepts `1e3` and ` 12 `; the wire wants the
|
|
155
163
|
* decimal literal the validator checks. A value ALREADY in that shape passes
|
|
156
164
|
* verbatim (`007` included) — a long integer or a tiny decimal must not be
|
|
@@ -206,6 +214,28 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
206
214
|
{...register(registerName as never, { setValueAs: canonicalNumber })}
|
|
207
215
|
/>
|
|
208
216
|
),
|
|
217
|
+
phone: ({ field, id, registerName, error, register }) => (
|
|
218
|
+
<Input
|
|
219
|
+
id={id}
|
|
220
|
+
type="tel"
|
|
221
|
+
inputMode="tel"
|
|
222
|
+
autoComplete="tel"
|
|
223
|
+
required={field.required}
|
|
224
|
+
aria-invalid={Boolean(error)}
|
|
225
|
+
placeholder={placeholderFor(field)}
|
|
226
|
+
{...register(registerName as never, { setValueAs: trimmed })}
|
|
227
|
+
/>
|
|
228
|
+
),
|
|
229
|
+
date: ({ field, id, registerName, error, register }) => (
|
|
230
|
+
// The native picker emits exactly the wire shape (`YYYY-MM-DD`).
|
|
231
|
+
<Input
|
|
232
|
+
id={id}
|
|
233
|
+
type="date"
|
|
234
|
+
required={field.required}
|
|
235
|
+
aria-invalid={Boolean(error)}
|
|
236
|
+
{...register(registerName as never)}
|
|
237
|
+
/>
|
|
238
|
+
),
|
|
209
239
|
select: ({ field, id, registerName, error, control }) => (
|
|
210
240
|
<Controller
|
|
211
241
|
control={control}
|
|
@@ -218,7 +248,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
218
248
|
<SelectContent>
|
|
219
249
|
{(field.options ?? []).map(opt => (
|
|
220
250
|
<SelectItem key={opt} value={opt}>
|
|
221
|
-
{opt}
|
|
251
|
+
{optionLabel(field, opt)}
|
|
222
252
|
</SelectItem>
|
|
223
253
|
))}
|
|
224
254
|
</SelectContent>
|
|
@@ -240,13 +270,43 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
240
270
|
{(field.options ?? []).map(opt => (
|
|
241
271
|
<div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
242
272
|
<RadioGroupItem id={`${id}-${opt}`} value={opt} />
|
|
243
|
-
<Label htmlFor={`${id}-${opt}`}>{opt}</Label>
|
|
273
|
+
<Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>
|
|
244
274
|
</div>
|
|
245
275
|
))}
|
|
246
276
|
</RadioGroup>
|
|
247
277
|
)}
|
|
248
278
|
/>
|
|
249
279
|
),
|
|
280
|
+
multiselect: ({ field, id, registerName, error, control }) => (
|
|
281
|
+
// HubSpot's "multiple checkboxes": the answer is its `;`-joined value list,
|
|
282
|
+
// held in that wire shape so the validator and the POST see one string.
|
|
283
|
+
<Controller
|
|
284
|
+
control={control}
|
|
285
|
+
name={registerName as never}
|
|
286
|
+
render={({ field: rhf }) => {
|
|
287
|
+
const selected = splitMultiValue(rhf.value);
|
|
288
|
+
const toggle = (opt: string, on: boolean) => {
|
|
289
|
+
const next = (field.options ?? []).filter(o => (o === opt ? on : selected.includes(o)));
|
|
290
|
+
rhf.onChange(next.join(MULTI_VALUE_SEPARATOR));
|
|
291
|
+
};
|
|
292
|
+
return (
|
|
293
|
+
<div id={id} role="group" className="flex flex-col gap-[var(--spacing-system-xs)]">
|
|
294
|
+
{(field.options ?? []).map(opt => (
|
|
295
|
+
<div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
296
|
+
<Checkbox
|
|
297
|
+
id={`${id}-${opt}`}
|
|
298
|
+
checked={selected.includes(opt)}
|
|
299
|
+
onCheckedChange={v => toggle(opt, v === true)}
|
|
300
|
+
aria-invalid={Boolean(error)}
|
|
301
|
+
/>
|
|
302
|
+
<Label htmlFor={`${id}-${opt}`}>{optionLabel(field, opt)}</Label>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
),
|
|
250
310
|
checkbox: ({ field, id, registerName, error, control }) => (
|
|
251
311
|
<Controller
|
|
252
312
|
control={control}
|
|
@@ -344,7 +404,10 @@ export function BookingForm({
|
|
|
344
404
|
getSignals,
|
|
345
405
|
}: BookingFormProps) {
|
|
346
406
|
const { formFields, legalConsent } = availability;
|
|
347
|
-
|
|
407
|
+
// Every declared question resolved to a control (`resolveFormFieldControl`) —
|
|
408
|
+
// an unfamiliar HubSpot type is drawn as its nearest control, never dropped
|
|
409
|
+
// silently and never a reason to hide the form.
|
|
410
|
+
const supportedFields = useMemo(() => normalizeFormFields(formFields), [formFields]);
|
|
348
411
|
// The DEFERRED schema in both flows: it is the wider of the two, and a strict
|
|
349
412
|
// resolver is not assignable to `Resolver<BookingFormValues>`. The strict
|
|
350
413
|
// schema is the server's contract — see `makeBookingSchema`'s docblock.
|
|
@@ -46,7 +46,7 @@ import { useHumanitySignals } from '../../hooks/use-humanity-signals';
|
|
|
46
46
|
import { BOOKING_IN_FLIGHT_MESSAGE, useMeetingBooking } from '../../hooks/use-meeting-booking';
|
|
47
47
|
import { useToast } from '../../hooks/use-toast';
|
|
48
48
|
import {
|
|
49
|
-
|
|
49
|
+
blocksNativeBooking,
|
|
50
50
|
type BookingConfirmation,
|
|
51
51
|
type MeetingAvailability,
|
|
52
52
|
type MeetingBookingErrorCode,
|
|
@@ -369,13 +369,16 @@ export function SchedulerDegradedCard({
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
/**
|
|
372
|
-
* Fail-closed gate: a link
|
|
373
|
-
*
|
|
374
|
-
* native form (a silently dropped required question or missing
|
|
375
|
-
* is worse than no native form) — the escape hatch takes over.
|
|
372
|
+
* Fail-closed gate: a link with a REQUIRED question no control can answer (a
|
|
373
|
+
* file upload), or whose consent block is malformed, must NOT render a
|
|
374
|
+
* half-working native form (a silently dropped required question or missing
|
|
375
|
+
* consent copy is worse than no native form) — the escape hatch takes over.
|
|
376
|
+
* An unfamiliar question TYPE is not a reason: it resolves to the nearest
|
|
377
|
+
* control (`resolveFormFieldControl`), so one new HubSpot type can no longer
|
|
378
|
+
* take the whole booking form down.
|
|
376
379
|
*/
|
|
377
380
|
function isNativelyBookable(availability: MeetingAvailability): boolean {
|
|
378
|
-
if (
|
|
381
|
+
if (availability.formFields.some(blocksNativeBooking)) return false;
|
|
379
382
|
const consent = availability.legalConsent;
|
|
380
383
|
if (consent) {
|
|
381
384
|
if (typeof consent.processingConsentText !== 'string') return false;
|
|
@@ -86,12 +86,25 @@ export interface SchedulingLinksPayload {
|
|
|
86
86
|
fetchedAt: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* One declared question as the host forwards it. `type` is HubSpot's
|
|
91
|
+
* `fieldType` (the control HubSpot draws: `phonenumber`, `booleancheckbox`…)
|
|
92
|
+
* VERBATIM, and `dataType` its `type` (the stored value's kind: `string`,
|
|
93
|
+
* `enumeration`, `phone_number`…). Neither is a closed set: the widget resolves
|
|
94
|
+
* ANY pair to a control through `resolveFormFieldControl`, so a type HubSpot
|
|
95
|
+
* adds tomorrow renders as the nearest control instead of taking the form down.
|
|
96
|
+
*/
|
|
89
97
|
export interface MeetingFormField {
|
|
90
98
|
name: string;
|
|
91
99
|
label: string;
|
|
92
100
|
type: string;
|
|
101
|
+
/** HubSpot's property data type — the fallback when `type` is unknown. Optional: older hosts omit it. */
|
|
102
|
+
dataType?: string;
|
|
93
103
|
required: boolean;
|
|
104
|
+
/** Option VALUES (what is submitted). */
|
|
94
105
|
options?: string[];
|
|
106
|
+
/** Option value → display label, when HubSpot's label differs from the value. */
|
|
107
|
+
optionLabels?: Record<string, string>;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
/**
|
|
@@ -158,22 +171,25 @@ export type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[numbe
|
|
|
158
171
|
// ---------------------------------------------------------------------------
|
|
159
172
|
|
|
160
173
|
/**
|
|
161
|
-
* THE registry of
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* here without a control is a COMPILE error, not a silent gap.
|
|
174
|
+
* THE registry of CONTROLS the native form can draw — one entry per control,
|
|
175
|
+
* and the ONLY place a control is declared. Everything else derives from it:
|
|
176
|
+
* `SupportedFormFieldType` is its key union, `SUPPORTED_FORM_FIELD_TYPES` its
|
|
177
|
+
* keys, and `makeBookingSchema` maps each answer through the entry's
|
|
178
|
+
* validator. The widget's control table (`booking-form.tsx`) is a `Record`
|
|
179
|
+
* over the same key union, so a control added here without a renderer is a
|
|
180
|
+
* COMPILE error, not a silent gap.
|
|
169
181
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
182
|
+
* These are controls, NOT HubSpot's type vocabulary. A HubSpot question
|
|
183
|
+
* reaches a control through `resolveFormFieldControl` (exact `fieldType`, then
|
|
184
|
+
* its data type, then a generic fallback), so the open set of HubSpot types
|
|
185
|
+
* maps onto this closed set of controls and no type is ever "unsupported".
|
|
186
|
+
* The one thing that still falls back to HubSpot's own page is a REQUIRED
|
|
187
|
+
* question no control can answer (a file upload) — see `blocksNativeBooking`.
|
|
172
188
|
*
|
|
173
|
-
* Every string
|
|
174
|
-
* `{ name, value: string }`); `checkbox` is the one boolean. `number` is
|
|
175
|
-
*
|
|
176
|
-
*
|
|
189
|
+
* Every string control rides the wire as a STRING (HubSpot's book endpoint
|
|
190
|
+
* takes `{ name, value: string }`); `checkbox` is the one boolean. `number` is
|
|
191
|
+
* a decimal literal, `multiselect` HubSpot's `;`-joined enumeration value,
|
|
192
|
+
* `date` an ISO `YYYY-MM-DD`.
|
|
177
193
|
*/
|
|
178
194
|
export interface FormFieldTypeSpec {
|
|
179
195
|
/** The answer's wire shape, which also decides how required/optional wraps it. */
|
|
@@ -199,6 +215,32 @@ const optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>
|
|
|
199
215
|
/** The wire shape of a number answer — what the form canonicalises TO and the validator checks. */
|
|
200
216
|
export const DECIMAL_LITERAL_RE = /^-?\d+(\.\d+)?$/;
|
|
201
217
|
|
|
218
|
+
/** HubSpot's separator for a multi-value enumeration (`a;b;c`). */
|
|
219
|
+
export const MULTI_VALUE_SEPARATOR = ';';
|
|
220
|
+
|
|
221
|
+
/** Split a multiselect answer into its values (empty string → none). */
|
|
222
|
+
export const splitMultiValue = (value: unknown): string[] =>
|
|
223
|
+
typeof value === 'string' && value ? value.split(MULTI_VALUE_SEPARATOR) : [];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A phone answer: digits with the punctuation people actually type (`+1 (415)
|
|
227
|
+
* 555-2671`, `020 7946 0958 ext. 12`). Deliberately a SHAPE check, not a
|
|
228
|
+
* numbering-plan check — HubSpot stores the string as typed, and rejecting a
|
|
229
|
+
* valid number we failed to parse is worse than accepting an odd one.
|
|
230
|
+
*/
|
|
231
|
+
export const PHONE_RE = /^\+?[\d\s().\-/]+(?:\s*(?:x|ext\.?)\s*\d+)?$/i;
|
|
232
|
+
const PHONE_MIN_DIGITS = 5;
|
|
233
|
+
const PHONE_MAX_DIGITS = 20;
|
|
234
|
+
const phoneDigits = (v: string) => v.replace(/\D/g, '').length;
|
|
235
|
+
|
|
236
|
+
/** A date answer as `<input type="date">` emits it. */
|
|
237
|
+
export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
238
|
+
const isRealIsoDate = (v: string) => {
|
|
239
|
+
if (!ISO_DATE_RE.test(v)) return false;
|
|
240
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
241
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
242
|
+
};
|
|
243
|
+
|
|
202
244
|
export const FORM_FIELD_TYPES = {
|
|
203
245
|
text: {
|
|
204
246
|
kind: 'string',
|
|
@@ -222,8 +264,33 @@ export const FORM_FIELD_TYPES = {
|
|
|
222
264
|
.max(32, { message: `${field.label} is too long` })
|
|
223
265
|
.regex(DECIMAL_LITERAL_RE, { message: `${field.label} must be a number` }),
|
|
224
266
|
},
|
|
267
|
+
phone: {
|
|
268
|
+
kind: 'string',
|
|
269
|
+
hasOptions: false,
|
|
270
|
+
validator: (field, base) =>
|
|
271
|
+
base
|
|
272
|
+
.max(40, { message: `${field.label} is too long` })
|
|
273
|
+
.refine(
|
|
274
|
+
v => !v || (PHONE_RE.test(v) && phoneDigits(v) >= PHONE_MIN_DIGITS && phoneDigits(v) <= PHONE_MAX_DIGITS),
|
|
275
|
+
{ message: `Please enter a valid phone number for ${field.label}` },
|
|
276
|
+
),
|
|
277
|
+
placeholder: field => `Enter ${field.label}`,
|
|
278
|
+
},
|
|
279
|
+
date: {
|
|
280
|
+
kind: 'string',
|
|
281
|
+
hasOptions: false,
|
|
282
|
+
validator: (field, base) => base.refine(v => !v || isRealIsoDate(v), { message: `${field.label} must be a date` }),
|
|
283
|
+
},
|
|
225
284
|
select: { kind: 'string', hasOptions: true, validator: optionValidator },
|
|
226
285
|
radio: { kind: 'string', hasOptions: true, validator: optionValidator },
|
|
286
|
+
multiselect: {
|
|
287
|
+
kind: 'string',
|
|
288
|
+
hasOptions: true,
|
|
289
|
+
validator: (field, base) =>
|
|
290
|
+
base.refine(v => splitMultiValue(v).every(part => (field.options ?? []).includes(part)), {
|
|
291
|
+
message: `Please choose valid options for ${field.label}`,
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
227
294
|
checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },
|
|
228
295
|
} as const satisfies Record<string, FormFieldTypeSpec>;
|
|
229
296
|
|
|
@@ -231,22 +298,158 @@ export type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;
|
|
|
231
298
|
|
|
232
299
|
export const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];
|
|
233
300
|
|
|
234
|
-
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// HubSpot type → control resolution (open set in, closed set out)
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* What a HubSpot question resolves to: a drawable control, `display` (a
|
|
307
|
+
* read-only block — `html`, `calculation_*` — never rendered, never
|
|
308
|
+
* submitted) or `unanswerable` (nothing the booking POST can carry, e.g. a
|
|
309
|
+
* file upload).
|
|
310
|
+
*/
|
|
311
|
+
export type FormFieldResolution = SupportedFormFieldType | 'display' | 'unanswerable';
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* HubSpot `fieldType` spellings → resolution. Keys are lower-cased. A spelling
|
|
315
|
+
* missing here is NOT an error: resolution falls through to the data type and
|
|
316
|
+
* then to a generic control. Add an entry only to draw a BETTER control for a
|
|
317
|
+
* type, never to make one "work".
|
|
318
|
+
*
|
|
319
|
+
* `checkbox` is absent on purpose: in HubSpot it is "multiple checkboxes" (an
|
|
320
|
+
* enumeration with options), while older links sent it for a single yes/no —
|
|
321
|
+
* `resolveFormFieldControl` decides by whether options came with it.
|
|
322
|
+
*/
|
|
323
|
+
const FIELD_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {
|
|
324
|
+
text: 'text',
|
|
325
|
+
textarea: 'textarea',
|
|
326
|
+
number: 'number',
|
|
327
|
+
select: 'select',
|
|
328
|
+
radio: 'radio',
|
|
329
|
+
booleancheckbox: 'checkbox',
|
|
330
|
+
phonenumber: 'phone',
|
|
331
|
+
date: 'date',
|
|
332
|
+
html: 'display',
|
|
333
|
+
file: 'unanswerable',
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** HubSpot property data types (`type`) → resolution, used when `fieldType` is unknown. */
|
|
337
|
+
const DATA_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {
|
|
338
|
+
string: 'text',
|
|
339
|
+
number: 'number',
|
|
340
|
+
enumeration: 'select',
|
|
341
|
+
bool: 'checkbox',
|
|
342
|
+
date: 'date',
|
|
343
|
+
datetime: 'date',
|
|
344
|
+
phone_number: 'phone',
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const hasDeclaredOptions = (field: MeetingFormField) => (field.options?.length ?? 0) > 0;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* THE one mapping from a HubSpot question to what the form does with it.
|
|
351
|
+
* Total: every input yields a resolution, so an unrecognised type can never
|
|
352
|
+
* make a link unbookable — at worst it is a text box. An option control with
|
|
353
|
+
* no options degrades to text (nothing to pick from, but still answerable).
|
|
354
|
+
*/
|
|
355
|
+
export function resolveFormFieldControl(field: MeetingFormField): FormFieldResolution {
|
|
356
|
+
const fieldType = (field.type ?? '').toLowerCase();
|
|
357
|
+
const withOptions = hasDeclaredOptions(field);
|
|
358
|
+
const usable = (r: FormFieldResolution | undefined): FormFieldResolution | undefined => {
|
|
359
|
+
if (!r || r === 'display' || r === 'unanswerable') return r;
|
|
360
|
+
return FORM_FIELD_TYPES[r].hasOptions && !withOptions ? 'text' : r;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
if (fieldType === 'checkbox') return withOptions ? 'multiselect' : 'checkbox';
|
|
364
|
+
if (fieldType.startsWith('calculation')) return 'display';
|
|
365
|
+
// A control name resolves to itself, so resolution is IDEMPOTENT: the form
|
|
366
|
+
// resolves once to draw, then hands the resolved fields to the schema
|
|
367
|
+
// factory, which resolves again (the server rebuild resolves raw ones).
|
|
368
|
+
const asControl = Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, fieldType)
|
|
369
|
+
? (fieldType as SupportedFormFieldType)
|
|
370
|
+
: undefined;
|
|
371
|
+
return (
|
|
372
|
+
usable(FIELD_TYPE_CONTROLS[fieldType] ?? asControl) ??
|
|
373
|
+
usable(DATA_TYPE_CONTROLS[(field.dataType ?? '').toLowerCase()]) ??
|
|
374
|
+
(withOptions ? 'select' : 'text')
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @deprecated Kept for hosts built against the closed six-type registry (they
|
|
380
|
+
* asked this to decide whether to forward `options`). Answers for any HubSpot
|
|
381
|
+
* type through the resolver: true when the type resolves to an option control
|
|
382
|
+
* once options are present. New hosts forward options whenever HubSpot sends
|
|
383
|
+
* them and do not need this.
|
|
384
|
+
*/
|
|
385
|
+
export function formFieldTypeHasOptions(type: string): boolean {
|
|
386
|
+
const control = resolveFormFieldControl({ name: '', label: '', type, required: false, options: ['_'] });
|
|
387
|
+
return control !== 'display' && control !== 'unanswerable' && FORM_FIELD_TYPES[control].hasOptions;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @deprecated The option controls (see `formFieldTypeHasOptions`). */
|
|
235
391
|
export const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(
|
|
236
392
|
type => FORM_FIELD_TYPES[type].hasOptions,
|
|
237
393
|
);
|
|
238
394
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Whether the question's `fieldType` is one the resolver maps EXPLICITLY — as
|
|
397
|
+
* opposed to guessing from its data type or falling back to text. A host logs
|
|
398
|
+
* the false case: the form still works, but a better control may be one table
|
|
399
|
+
* entry away.
|
|
400
|
+
*/
|
|
401
|
+
export function isRecognisedFormFieldType(type: string): boolean {
|
|
402
|
+
const t = (type ?? '').toLowerCase();
|
|
403
|
+
return (
|
|
404
|
+
t === 'checkbox' ||
|
|
405
|
+
t.startsWith('calculation') ||
|
|
406
|
+
Object.prototype.hasOwnProperty.call(FIELD_TYPE_CONTROLS, t) ||
|
|
407
|
+
Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, t)
|
|
408
|
+
);
|
|
242
409
|
}
|
|
243
410
|
|
|
244
|
-
|
|
245
|
-
|
|
411
|
+
/** A declared question after resolution: `type` is the CONTROL, `hubspotType` what HubSpot sent. */
|
|
412
|
+
export type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType; hubspotType?: string };
|
|
413
|
+
|
|
414
|
+
/** One question → its drawable form, or null when the form does not draw it (display / unanswerable). */
|
|
415
|
+
export function normalizeFormField(
|
|
416
|
+
field: MeetingFormField & { hubspotType?: string },
|
|
417
|
+
): SupportedMeetingFormField | null {
|
|
418
|
+
const control = resolveFormFieldControl(field);
|
|
419
|
+
if (control === 'display' || control === 'unanswerable') return null;
|
|
420
|
+
// A control that takes no options drops them (a `booleancheckbox` arrives
|
|
421
|
+
// with HubSpot's `true`/`false` pair), which also keeps a re-resolution of
|
|
422
|
+
// the result on the same control.
|
|
423
|
+
const normalized: SupportedMeetingFormField = {
|
|
424
|
+
...field,
|
|
425
|
+
type: control,
|
|
426
|
+
hubspotType: field.hubspotType ?? field.type,
|
|
427
|
+
};
|
|
428
|
+
if (!FORM_FIELD_TYPES[control].hasOptions) {
|
|
429
|
+
delete normalized.options;
|
|
430
|
+
delete normalized.optionLabels;
|
|
431
|
+
}
|
|
432
|
+
return normalized;
|
|
246
433
|
}
|
|
247
434
|
|
|
248
|
-
/**
|
|
249
|
-
export
|
|
435
|
+
/** The questions the native form draws and validates, in declared order. */
|
|
436
|
+
export function normalizeFormFields(fields: readonly MeetingFormField[]): SupportedMeetingFormField[] {
|
|
437
|
+
return fields.flatMap(f => normalizeFormField(f) ?? []);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Whether the native form draws this question (false for display blocks and unanswerable questions). */
|
|
441
|
+
export function isSupportedFormField(field: MeetingFormField): boolean {
|
|
442
|
+
return normalizeFormField(field) !== null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The ONLY reason left to hand a link to HubSpot's own page: a REQUIRED
|
|
447
|
+
* question no control can answer. An optional one is simply left out — the
|
|
448
|
+
* visitor can still book, and HubSpot does not require it.
|
|
449
|
+
*/
|
|
450
|
+
export function blocksNativeBooking(field: MeetingFormField): boolean {
|
|
451
|
+
return field.required && resolveFormFieldControl(field) === 'unanswerable';
|
|
452
|
+
}
|
|
250
453
|
|
|
251
454
|
/**
|
|
252
455
|
* The scheduler's fixed identity fields. HubSpot's book endpoint takes them
|
|
@@ -381,8 +584,11 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
|
|
|
381
584
|
slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },
|
|
382
585
|
) {
|
|
383
586
|
const answers: Record<string, z.ZodTypeAny> = {};
|
|
384
|
-
|
|
385
|
-
|
|
587
|
+
// Resolved HERE, not by the caller: the server rebuild passes the link's raw
|
|
588
|
+
// questions, so client and server resolve every type through the same rule.
|
|
589
|
+
// Display blocks and unanswerable optional questions carry no answer.
|
|
590
|
+
const drawn = normalizeFormFields(formFields);
|
|
591
|
+
for (const field of drawn) {
|
|
386
592
|
const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];
|
|
387
593
|
// required/optional wrapping follows the answer's wire KIND, not its type:
|
|
388
594
|
// a boolean is true-or-absent, a string is non-empty-or-empty. For strings
|
|
@@ -407,7 +613,7 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
|
|
|
407
613
|
// omitting the `formFields` key entirely would skip every per-question
|
|
408
614
|
// rule. When the link declares at least one required supported question,
|
|
409
615
|
// the object itself is required.
|
|
410
|
-
const hasRequiredAnswers =
|
|
616
|
+
const hasRequiredAnswers = drawn.some(f => f.required);
|
|
411
617
|
const answersObject = z.object(answers);
|
|
412
618
|
|
|
413
619
|
return (
|
|
@@ -100,14 +100,23 @@ describe('makeBookingSchema × SUPPORTED_FORM_FIELD_TYPES', () => {
|
|
|
100
100
|
label: `Question (${type})`,
|
|
101
101
|
type,
|
|
102
102
|
required: true,
|
|
103
|
-
options: type === 'select' || type === 'radio' ? ['a', 'b'] : undefined,
|
|
103
|
+
options: type === 'select' || type === 'radio' || type === 'multiselect' ? ['a', 'b'] : undefined,
|
|
104
104
|
};
|
|
105
105
|
expect(isSupportedFormField(field)).toBe(true);
|
|
106
106
|
const schema = makeBookingSchema([field], null);
|
|
107
|
-
// One well-formed answer per
|
|
108
|
-
// pickers, a decimal literal for a
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// One well-formed answer per control: a boolean for the box, an option for the
|
|
108
|
+
// pickers, `;`-joined options for the multi-picker, a decimal literal for a
|
|
109
|
+
// Number property, a phone and an ISO date for theirs, free text otherwise.
|
|
110
|
+
const ANSWERS: Record<string, unknown> = {
|
|
111
|
+
checkbox: true,
|
|
112
|
+
select: 'a',
|
|
113
|
+
radio: 'a',
|
|
114
|
+
multiselect: 'a;b',
|
|
115
|
+
number: '42',
|
|
116
|
+
phone: '+1 415 555 2671',
|
|
117
|
+
date: '2026-09-24',
|
|
118
|
+
};
|
|
119
|
+
const answer = ANSWERS[type] ?? 'hello';
|
|
111
120
|
const ok = schema.safeParse({ ...base, formFields: { [field.name]: answer } });
|
|
112
121
|
expect(ok.success).toBe(true);
|
|
113
122
|
const missing = schema.safeParse({ ...base, formFields: {} });
|
|
@@ -115,7 +124,7 @@ describe('makeBookingSchema × SUPPORTED_FORM_FIELD_TYPES', () => {
|
|
|
115
124
|
}
|
|
116
125
|
});
|
|
117
126
|
|
|
118
|
-
it('
|
|
127
|
+
it('an unanswerable question (file upload) is excluded from validation (fail-closed happens at render)', () => {
|
|
119
128
|
const field: MeetingFormField = { name: 'q_file', label: 'Upload', type: 'file', required: true };
|
|
120
129
|
expect(isSupportedFormField(field)).toBe(false);
|
|
121
130
|
const schema = makeBookingSchema([field], null);
|