@flamingo-stack/openframe-frontend-core 0.0.664 → 0.0.665-2372.7061.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.
@@ -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
- isSupportedFormField,
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 whose declared questions include an unsupported
373
- * type, or whose consent block is malformed, must NOT render a half-working
374
- * native form (a silently dropped required question or missing consent copy
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 (!availability.formFields.every(isSupportedFormField)) return false;
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 HubSpot question types the native form supports — one entry
162
- * per `fieldType`, and the ONLY place a type is declared. Everything else
163
- * derives from it: `SupportedFormFieldType` is its key union,
164
- * `SUPPORTED_FORM_FIELD_TYPES` its keys, `FORM_FIELD_TYPES_WITH_OPTIONS` the
165
- * entries that carry `options`, and `makeBookingSchema` maps each answer
166
- * through the entry's validator. The widget's control table
167
- * (`booking-form.tsx`) is a `Record` over the same key union, so a type added
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
- * Fail-closed: a `fieldType` with no entry makes the link "not natively
171
- * bookable" and the card falls back to the HubSpot escape hatch.
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 type rides the wire as a STRING (HubSpot's book endpoint takes
174
- * `{ name, value: string }`); `checkbox` is the one boolean. `number` is a
175
- * Number property validated as a decimal literal — what `<input type="number">`
176
- * emits and what the property stores.
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,141 @@ 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
- /** The types whose `options` the host must forward (select, radio). */
235
- export const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(
236
- type => FORM_FIELD_TYPES[type].hasOptions,
237
- );
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;
238
348
 
239
- /** Whether a raw HubSpot field type is one whose answers come from declared options. */
240
- export function formFieldTypeHasOptions(type: string): boolean {
241
- return (FORM_FIELD_TYPES_WITH_OPTIONS as readonly string[]).includes(type);
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
+ );
242
376
  }
243
377
 
244
- export function isSupportedFormField(field: MeetingFormField): field is SupportedMeetingFormField {
245
- return Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, field.type);
378
+ /**
379
+ * Whether the question's `fieldType` is one the resolver maps EXPLICITLY — as
380
+ * opposed to guessing from its data type or falling back to text. A host logs
381
+ * the false case: the form still works, but a better control may be one table
382
+ * entry away.
383
+ */
384
+ export function isRecognisedFormFieldType(type: string): boolean {
385
+ const t = (type ?? '').toLowerCase();
386
+ return (
387
+ t === 'checkbox' ||
388
+ t.startsWith('calculation') ||
389
+ Object.prototype.hasOwnProperty.call(FIELD_TYPE_CONTROLS, t) ||
390
+ Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, t)
391
+ );
392
+ }
393
+
394
+ /** A declared question after resolution: `type` is the CONTROL, `hubspotType` what HubSpot sent. */
395
+ export type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType; hubspotType?: string };
396
+
397
+ /** One question → its drawable form, or null when the form does not draw it (display / unanswerable). */
398
+ export function normalizeFormField(
399
+ field: MeetingFormField & { hubspotType?: string },
400
+ ): SupportedMeetingFormField | null {
401
+ const control = resolveFormFieldControl(field);
402
+ if (control === 'display' || control === 'unanswerable') return null;
403
+ // A control that takes no options drops them (a `booleancheckbox` arrives
404
+ // with HubSpot's `true`/`false` pair), which also keeps a re-resolution of
405
+ // the result on the same control.
406
+ const normalized: SupportedMeetingFormField = {
407
+ ...field,
408
+ type: control,
409
+ hubspotType: field.hubspotType ?? field.type,
410
+ };
411
+ if (!FORM_FIELD_TYPES[control].hasOptions) {
412
+ delete normalized.options;
413
+ delete normalized.optionLabels;
414
+ }
415
+ return normalized;
416
+ }
417
+
418
+ /** The questions the native form draws and validates, in declared order. */
419
+ export function normalizeFormFields(fields: readonly MeetingFormField[]): SupportedMeetingFormField[] {
420
+ return fields.flatMap(f => normalizeFormField(f) ?? []);
246
421
  }
247
422
 
248
- /** A declared question whose `type` is in the registry. */
249
- export type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType };
423
+ /** Whether the native form draws this question (false for display blocks and unanswerable questions). */
424
+ export function isSupportedFormField(field: MeetingFormField): boolean {
425
+ return normalizeFormField(field) !== null;
426
+ }
427
+
428
+ /**
429
+ * The ONLY reason left to hand a link to HubSpot's own page: a REQUIRED
430
+ * question no control can answer. An optional one is simply left out — the
431
+ * visitor can still book, and HubSpot does not require it.
432
+ */
433
+ export function blocksNativeBooking(field: MeetingFormField): boolean {
434
+ return field.required && resolveFormFieldControl(field) === 'unanswerable';
435
+ }
250
436
 
251
437
  /**
252
438
  * The scheduler's fixed identity fields. HubSpot's book endpoint takes them
@@ -381,8 +567,11 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
381
567
  slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },
382
568
  ) {
383
569
  const answers: Record<string, z.ZodTypeAny> = {};
384
- for (const field of formFields) {
385
- if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time
570
+ // Resolved HERE, not by the caller: the server rebuild passes the link's raw
571
+ // questions, so client and server resolve every type through the same rule.
572
+ // Display blocks and unanswerable optional questions carry no answer.
573
+ const drawn = normalizeFormFields(formFields);
574
+ for (const field of drawn) {
386
575
  const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];
387
576
  // required/optional wrapping follows the answer's wire KIND, not its type:
388
577
  // a boolean is true-or-absent, a string is non-empty-or-empty. For strings
@@ -407,7 +596,7 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
407
596
  // omitting the `formFields` key entirely would skip every per-question
408
597
  // rule. When the link declares at least one required supported question,
409
598
  // the object itself is required.
410
- const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);
599
+ const hasRequiredAnswers = drawn.some(f => f.required);
411
600
  const answersObject = z.object(answers);
412
601
 
413
602
  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 type: a boolean for the box, an option for the
108
- // pickers, a decimal literal for a Number property, free text otherwise.
109
- const answer =
110
- type === 'checkbox' ? true : type === 'select' || type === 'radio' ? 'a' : type === 'number' ? '42' : 'hello';
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('unsupported types are excluded from validation (fail-closed happens at render)', () => {
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);