@flamingo-stack/openframe-frontend-core 0.0.596-2034.5677.1 → 0.0.596-2034.5679.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/__tests__/fixtures/meeting-booking.d.ts +15 -0
- package/dist/__tests__/fixtures/meeting-booking.d.ts.map +1 -0
- package/dist/{chunk-JFP4Q54D.cjs → chunk-2ALINRGB.cjs} +57 -53
- package/dist/chunk-2ALINRGB.cjs.map +1 -0
- package/dist/{chunk-6OEMFFPL.js → chunk-2RZ6YM3C.js} +3 -3
- package/dist/{chunk-W67TKD2D.js → chunk-4SFVZYWV.js} +2 -2
- package/dist/{chunk-NVFNTGJ3.cjs → chunk-4SKIQ22B.cjs} +17 -17
- package/dist/{chunk-NVFNTGJ3.cjs.map → chunk-4SKIQ22B.cjs.map} +1 -1
- package/dist/{chunk-S63CPX6M.cjs → chunk-57YTCN3M.cjs} +10 -10
- package/dist/{chunk-S63CPX6M.cjs.map → chunk-57YTCN3M.cjs.map} +1 -1
- package/dist/{chunk-ARAOF75O.cjs → chunk-6DKB2XCW.cjs} +24 -23
- package/dist/chunk-6DKB2XCW.cjs.map +1 -0
- package/dist/{chunk-S7XYZUSN.cjs → chunk-ACOT5IOG.cjs} +4 -4
- package/dist/{chunk-S7XYZUSN.cjs.map → chunk-ACOT5IOG.cjs.map} +1 -1
- package/dist/{chunk-CMLJ7FBX.js → chunk-ACQUDYAL.js} +9 -8
- package/dist/{chunk-CMLJ7FBX.js.map → chunk-ACQUDYAL.js.map} +1 -1
- package/dist/{chunk-X4DUWSKC.cjs → chunk-BC6XC7NK.cjs} +3 -3
- package/dist/{chunk-X4DUWSKC.cjs.map → chunk-BC6XC7NK.cjs.map} +1 -1
- package/dist/{chunk-5WYFPUOS.js → chunk-CFLLMOY2.js} +2 -2
- package/dist/{chunk-ICZTKVDG.cjs → chunk-CGVDY2GB.cjs} +33 -33
- package/dist/{chunk-ICZTKVDG.cjs.map → chunk-CGVDY2GB.cjs.map} +1 -1
- package/dist/{chunk-7EYGQ7E2.js → chunk-DFM6HEKQ.js} +2 -2
- package/dist/{chunk-FBI4I4ON.js → chunk-EYOY7HOP.js} +9 -8
- package/dist/chunk-EYOY7HOP.js.map +1 -0
- package/dist/{chunk-BY7JATI5.cjs → chunk-GEB35FWX.cjs} +34 -34
- package/dist/{chunk-BY7JATI5.cjs.map → chunk-GEB35FWX.cjs.map} +1 -1
- package/dist/{chunk-RNSN34DW.js → chunk-I2SEUB73.js} +2 -2
- package/dist/{chunk-RZY4YKSZ.js → chunk-JOIOGAZU.js} +11 -7
- package/dist/chunk-JOIOGAZU.js.map +1 -0
- package/dist/{chunk-UKT2FAPZ.cjs → chunk-JRDS7YKY.cjs} +11 -4
- package/dist/chunk-JRDS7YKY.cjs.map +1 -0
- package/dist/{chunk-ZPXDUMBH.cjs → chunk-JSLEFVSM.cjs} +3 -3
- package/dist/{chunk-ZPXDUMBH.cjs.map → chunk-JSLEFVSM.cjs.map} +1 -1
- package/dist/{chunk-EUFJ6EIQ.js → chunk-KHYYG2QT.js} +4 -4
- package/dist/{chunk-JMPGI6RA.js → chunk-N4Y5LTOM.js} +22 -15
- package/dist/chunk-N4Y5LTOM.js.map +1 -0
- package/dist/{chunk-7MVJWPTD.js → chunk-OMUVPDIN.js} +2 -2
- package/dist/{chunk-WT5POYIW.js → chunk-OQO46ABG.js} +2 -2
- package/dist/{chunk-W7EJJDV3.cjs → chunk-RBH327ON.cjs} +16 -16
- package/dist/{chunk-W7EJJDV3.cjs.map → chunk-RBH327ON.cjs.map} +1 -1
- package/dist/{chunk-2QVM3X76.cjs → chunk-SGVCEO2K.cjs} +17 -17
- package/dist/{chunk-2QVM3X76.cjs.map → chunk-SGVCEO2K.cjs.map} +1 -1
- package/dist/{chunk-LPFXWIO7.js → chunk-U3P6ASXQ.js} +4 -4
- package/dist/{chunk-KE5PA54A.cjs → chunk-VARYIAHY.cjs} +4 -4
- package/dist/{chunk-KE5PA54A.cjs.map → chunk-VARYIAHY.cjs.map} +1 -1
- package/dist/{chunk-K2ZRTOEQ.cjs → chunk-WXPKY5JR.cjs} +3 -3
- package/dist/{chunk-K2ZRTOEQ.cjs.map → chunk-WXPKY5JR.cjs.map} +1 -1
- package/dist/{chunk-IJP53PTA.cjs → chunk-WXVICYLL.cjs} +54 -53
- package/dist/chunk-WXVICYLL.cjs.map +1 -0
- package/dist/{chunk-YYQQAGB3.js → chunk-X4GV5N4U.js} +5 -5
- package/dist/{chunk-XXCWE33Z.js → chunk-XMRWTKSO.js} +2 -2
- package/dist/{chunk-3YFRCUEE.js → chunk-XRGQBXUP.js} +3 -3
- package/dist/{chunk-AKWRURNW.cjs → chunk-XURV4T7S.cjs} +3 -3
- package/dist/{chunk-AKWRURNW.cjs.map → chunk-XURV4T7S.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/contact/contact-form.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +35 -33
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +16 -14
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts +0 -14
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +285 -198
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts +26 -2
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +255 -168
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/tickets/help-center-create-form.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/benefit-card.d.ts.map +1 -1
- package/dist/components/ui/checkbox-block.d.ts +2 -0
- package/dist/components/ui/checkbox-block.d.ts.map +1 -1
- package/dist/components/ui/field-wrapper.d.ts.map +1 -1
- package/dist/components/ui/field.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +4 -4
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +5 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +5 -3
- package/dist/components/ui/required-mark.d.ts +8 -0
- package/dist/components/ui/required-mark.d.ts.map +1 -0
- package/dist/index.cjs +9 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +1 -1
- package/dist/schemas/meeting-booking-schema.cjs +86 -31
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +123 -9
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +83 -32
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/booking-form-number-field.test.tsx +21 -16
- package/src/__tests__/fixtures/meeting-booking.ts +27 -0
- package/src/__tests__/meeting-booking-schema.test.ts +2 -9
- package/src/components/contact/contact-form.tsx +9 -4
- package/src/components/meeting-scheduler/booking-form.tsx +208 -148
- package/src/components/meeting-scheduler/index.tsx +77 -40
- package/src/components/tickets/help-center-create-form.tsx +3 -1
- package/src/components/ui/benefit-card.tsx +4 -3
- package/src/components/ui/checkbox-block.tsx +5 -0
- package/src/components/ui/field-wrapper.tsx +2 -5
- package/src/components/ui/field.tsx +2 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/required-mark.tsx +13 -0
- package/src/schemas/meeting-booking-schema.ts +163 -55
- package/src/utils/__tests__/hubspot-meetings-convention.test.ts +2 -10
- package/dist/chunk-ARAOF75O.cjs.map +0 -1
- package/dist/chunk-FBI4I4ON.js.map +0 -1
- package/dist/chunk-IJP53PTA.cjs.map +0 -1
- package/dist/chunk-JFP4Q54D.cjs.map +0 -1
- package/dist/chunk-JMPGI6RA.js.map +0 -1
- package/dist/chunk-RZY4YKSZ.js.map +0 -1
- package/dist/chunk-UKT2FAPZ.cjs.map +0 -1
- /package/dist/{chunk-6OEMFFPL.js.map → chunk-2RZ6YM3C.js.map} +0 -0
- /package/dist/{chunk-W67TKD2D.js.map → chunk-4SFVZYWV.js.map} +0 -0
- /package/dist/{chunk-5WYFPUOS.js.map → chunk-CFLLMOY2.js.map} +0 -0
- /package/dist/{chunk-7EYGQ7E2.js.map → chunk-DFM6HEKQ.js.map} +0 -0
- /package/dist/{chunk-RNSN34DW.js.map → chunk-I2SEUB73.js.map} +0 -0
- /package/dist/{chunk-EUFJ6EIQ.js.map → chunk-KHYYG2QT.js.map} +0 -0
- /package/dist/{chunk-7MVJWPTD.js.map → chunk-OMUVPDIN.js.map} +0 -0
- /package/dist/{chunk-WT5POYIW.js.map → chunk-OQO46ABG.js.map} +0 -0
- /package/dist/{chunk-LPFXWIO7.js.map → chunk-U3P6ASXQ.js.map} +0 -0
- /package/dist/{chunk-YYQQAGB3.js.map → chunk-X4GV5N4U.js.map} +0 -0
- /package/dist/{chunk-XXCWE33Z.js.map → chunk-XMRWTKSO.js.map} +0 -0
- /package/dist/{chunk-3YFRCUEE.js.map → chunk-XRGQBXUP.js.map} +0 -0
|
@@ -136,16 +136,130 @@ export declare const MEETING_BOOKING_ERROR_CODES: readonly ["SLOT_TAKEN", "VALID
|
|
|
136
136
|
*/
|
|
137
137
|
export type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];
|
|
138
138
|
/**
|
|
139
|
-
* HubSpot
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* the
|
|
139
|
+
* THE registry of HubSpot question types the native form supports — one entry
|
|
140
|
+
* per `fieldType`, and the ONLY place a type is declared. Everything else
|
|
141
|
+
* derives from it: `SupportedFormFieldType` is its key union,
|
|
142
|
+
* `SUPPORTED_FORM_FIELD_TYPES` its keys, `FORM_FIELD_TYPES_WITH_OPTIONS` the
|
|
143
|
+
* entries that carry `options`, and `makeBookingSchema` maps each answer
|
|
144
|
+
* through the entry's validator. The widget's control table
|
|
145
|
+
* (`booking-form.tsx`) is a `Record` over the same key union, so a type added
|
|
146
|
+
* here without a control is a COMPILE error, not a silent gap.
|
|
147
|
+
*
|
|
148
|
+
* Fail-closed: a `fieldType` with no entry makes the link "not natively
|
|
149
|
+
* bookable" and the card falls back to the HubSpot escape hatch.
|
|
150
|
+
*
|
|
151
|
+
* Every string type rides the wire as a STRING (HubSpot's book endpoint takes
|
|
152
|
+
* `{ name, value: string }`); `checkbox` is the one boolean. `number` is a
|
|
153
|
+
* Number property validated as a decimal literal — what `<input type="number">`
|
|
154
|
+
* emits and what the property stores.
|
|
155
|
+
*/
|
|
156
|
+
export interface FormFieldTypeSpec {
|
|
157
|
+
/** The answer's wire shape, which also decides how required/optional wraps it. */
|
|
158
|
+
kind: 'string' | 'boolean';
|
|
159
|
+
/** Whether HubSpot publishes `options` for the type (the pickers). */
|
|
160
|
+
hasOptions: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* The validator for ONE answer. String types EXTEND `base`, which already
|
|
163
|
+
* carries the required-ness (`min(1)` when required) — so "X is required" is
|
|
164
|
+
* the first issue reported for an empty answer, ahead of the type's own rule.
|
|
165
|
+
* Boolean types ignore it.
|
|
166
|
+
*/
|
|
167
|
+
validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;
|
|
168
|
+
/** The control's placeholder, derived from the field (the mock's "Enter Company Name"). */
|
|
169
|
+
placeholder?: (field: MeetingFormField) => string;
|
|
170
|
+
}
|
|
171
|
+
export declare const FORM_FIELD_TYPES: {
|
|
172
|
+
readonly text: {
|
|
173
|
+
readonly kind: "string";
|
|
174
|
+
readonly hasOptions: false;
|
|
175
|
+
readonly validator: (field: MeetingFormField, base: z.ZodString) => z.ZodString;
|
|
176
|
+
readonly placeholder: (field: MeetingFormField) => string;
|
|
177
|
+
};
|
|
178
|
+
readonly textarea: {
|
|
179
|
+
readonly kind: "string";
|
|
180
|
+
readonly hasOptions: false;
|
|
181
|
+
readonly validator: (field: MeetingFormField, base: z.ZodString) => z.ZodString;
|
|
182
|
+
readonly placeholder: (field: MeetingFormField) => string;
|
|
183
|
+
};
|
|
184
|
+
readonly number: {
|
|
185
|
+
readonly kind: "string";
|
|
186
|
+
readonly hasOptions: false;
|
|
187
|
+
readonly validator: (field: MeetingFormField, base: z.ZodString) => z.ZodString;
|
|
188
|
+
};
|
|
189
|
+
readonly select: {
|
|
190
|
+
readonly kind: "string";
|
|
191
|
+
readonly hasOptions: true;
|
|
192
|
+
readonly validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;
|
|
193
|
+
};
|
|
194
|
+
readonly radio: {
|
|
195
|
+
readonly kind: "string";
|
|
196
|
+
readonly hasOptions: true;
|
|
197
|
+
readonly validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;
|
|
198
|
+
};
|
|
199
|
+
readonly checkbox: {
|
|
200
|
+
readonly kind: "boolean";
|
|
201
|
+
readonly hasOptions: false;
|
|
202
|
+
readonly validator: () => z.ZodBoolean;
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
export type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;
|
|
206
|
+
export declare const SUPPORTED_FORM_FIELD_TYPES: readonly SupportedFormFieldType[];
|
|
207
|
+
/** The types whose `options` the host must forward (select, radio). */
|
|
208
|
+
export declare const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[];
|
|
209
|
+
export declare function isSupportedFormField(field: MeetingFormField): field is SupportedMeetingFormField;
|
|
210
|
+
/** A declared question whose `type` is in the registry. */
|
|
211
|
+
export type SupportedMeetingFormField = MeetingFormField & {
|
|
212
|
+
type: SupportedFormFieldType;
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* The scheduler's fixed identity fields. HubSpot's book endpoint takes them
|
|
216
|
+
* TOP-LEVEL (`firstName`, `lastName`, `email`), its own booking page hardcodes
|
|
217
|
+
* them, and the link's `formFields` never lists them — so they are data HERE,
|
|
218
|
+
* rendered by the widget through the SAME control path as every declared
|
|
219
|
+
* question, rather than three hand-written blocks. `inputType`/`autoComplete`
|
|
220
|
+
* are the browser hints a text control takes; the wire and the validator do
|
|
221
|
+
* not see them.
|
|
145
222
|
*/
|
|
146
|
-
export
|
|
147
|
-
|
|
148
|
-
|
|
223
|
+
export interface BuiltInBookingField extends MeetingFormField {
|
|
224
|
+
type: 'text';
|
|
225
|
+
required: true;
|
|
226
|
+
inputType?: 'email';
|
|
227
|
+
autoComplete: string;
|
|
228
|
+
placeholder: string;
|
|
229
|
+
/** The wire's own required/format message — kept verbatim from the schema it replaced. */
|
|
230
|
+
requiredMessage: string;
|
|
231
|
+
}
|
|
232
|
+
export declare const BUILT_IN_BOOKING_FIELDS: readonly [{
|
|
233
|
+
readonly name: "firstName";
|
|
234
|
+
readonly label: "First Name";
|
|
235
|
+
readonly type: "text";
|
|
236
|
+
readonly required: true;
|
|
237
|
+
readonly autoComplete: "given-name";
|
|
238
|
+
readonly placeholder: "Enter First Name";
|
|
239
|
+
readonly requiredMessage: "First name is required";
|
|
240
|
+
}, {
|
|
241
|
+
readonly name: "lastName";
|
|
242
|
+
readonly label: "Last Name";
|
|
243
|
+
readonly type: "text";
|
|
244
|
+
readonly required: true;
|
|
245
|
+
readonly autoComplete: "family-name";
|
|
246
|
+
readonly placeholder: "Enter Last Name";
|
|
247
|
+
readonly requiredMessage: "Last name is required";
|
|
248
|
+
}, {
|
|
249
|
+
readonly name: "email";
|
|
250
|
+
readonly label: "Email";
|
|
251
|
+
readonly type: "text";
|
|
252
|
+
readonly required: true;
|
|
253
|
+
readonly inputType: "email";
|
|
254
|
+
readonly autoComplete: "email";
|
|
255
|
+
readonly placeholder: "username@mail.com";
|
|
256
|
+
readonly requiredMessage: "Please enter a valid email address";
|
|
257
|
+
}];
|
|
258
|
+
/** `'firstName' | 'lastName' | 'email'` — derived from the array, never restated. */
|
|
259
|
+
export type BuiltInBookingFieldName = (typeof BUILT_IN_BOOKING_FIELDS)[number]['name'];
|
|
260
|
+
/** The registry entry for a supported type, widened to the spec so optional
|
|
261
|
+
* members (`placeholder`) are readable without narrowing on the union. */
|
|
262
|
+
export declare function fieldTypeSpec(type: SupportedFormFieldType): FormFieldTypeSpec;
|
|
149
263
|
/**
|
|
150
264
|
* IANA timezone check. Shape prefilter, then the authoritative resolution
|
|
151
265
|
* test: `Intl.DateTimeFormat` throws on unknown zones. NOT
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meeting-booking-schema.d.ts","sourceRoot":"","sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AAMH;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC5C,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,8GAA8G;IAC9G,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACzC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;IAC1B,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,wEAAwE;IACxE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7E,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,wBAAwB,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,8BAA8B,EAAE,KAAK,CAAC;QACpC,mBAAmB,EAAE,MAAM,CAAC;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC,CAAC;IACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,uHAO9B,CAAC;AAEX;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,OAAO,2BAA2B,CAAC,CAAC,MAAM,CAAC,CAAC;AAMnF
|
|
1
|
+
{"version":3,"file":"meeting-booking-schema.d.ts","sourceRoot":"","sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -9,9 +9,78 @@ var MEETING_BOOKING_ERROR_CODES = [
|
|
|
9
9
|
"TEMPORARILY_UNAVAILABLE",
|
|
10
10
|
"MEETING_UNAVAILABLE"
|
|
11
11
|
];
|
|
12
|
-
var
|
|
12
|
+
var optionValidator = (field, base) => base.refine((v) => !v || (field.options ?? []).includes(v), {
|
|
13
|
+
message: `Please choose a valid option for ${field.label}`
|
|
14
|
+
});
|
|
15
|
+
var FORM_FIELD_TYPES = {
|
|
16
|
+
text: {
|
|
17
|
+
kind: "string",
|
|
18
|
+
hasOptions: false,
|
|
19
|
+
validator: (field, base) => base.max(1e3, { message: `${field.label} is too long` }),
|
|
20
|
+
placeholder: (field) => `Enter ${field.label}`
|
|
21
|
+
},
|
|
22
|
+
textarea: {
|
|
23
|
+
kind: "string",
|
|
24
|
+
hasOptions: false,
|
|
25
|
+
validator: (field, base) => base.max(5e3, { message: `${field.label} is too long` }),
|
|
26
|
+
placeholder: (field) => `Enter text${field.required ? "" : " (optional)"}`
|
|
27
|
+
},
|
|
28
|
+
number: {
|
|
29
|
+
kind: "string",
|
|
30
|
+
hasOptions: false,
|
|
31
|
+
// Canonical decimal literal — the control normalises what the browser
|
|
32
|
+
// accepts (`1e3`, `007`) to this before it is validated or sent.
|
|
33
|
+
validator: (field, base) => base.max(32, { message: `${field.label} is too long` }).regex(/^-?\d+(\.\d+)?$/, { message: `${field.label} must be a number` })
|
|
34
|
+
},
|
|
35
|
+
select: { kind: "string", hasOptions: true, validator: optionValidator },
|
|
36
|
+
radio: { kind: "string", hasOptions: true, validator: optionValidator },
|
|
37
|
+
checkbox: { kind: "boolean", hasOptions: false, validator: () => z.boolean() }
|
|
38
|
+
};
|
|
39
|
+
var SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES);
|
|
40
|
+
var FORM_FIELD_TYPES_WITH_OPTIONS = SUPPORTED_FORM_FIELD_TYPES.filter(
|
|
41
|
+
(type) => FORM_FIELD_TYPES[type].hasOptions
|
|
42
|
+
);
|
|
13
43
|
function isSupportedFormField(field) {
|
|
14
|
-
return
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, field.type);
|
|
45
|
+
}
|
|
46
|
+
var BUILT_IN_BOOKING_FIELDS = [
|
|
47
|
+
{
|
|
48
|
+
name: "firstName",
|
|
49
|
+
label: "First Name",
|
|
50
|
+
type: "text",
|
|
51
|
+
required: true,
|
|
52
|
+
autoComplete: "given-name",
|
|
53
|
+
placeholder: "Enter First Name",
|
|
54
|
+
requiredMessage: "First name is required"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "lastName",
|
|
58
|
+
label: "Last Name",
|
|
59
|
+
type: "text",
|
|
60
|
+
required: true,
|
|
61
|
+
autoComplete: "family-name",
|
|
62
|
+
placeholder: "Enter Last Name",
|
|
63
|
+
requiredMessage: "Last name is required"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "email",
|
|
67
|
+
label: "Email",
|
|
68
|
+
type: "text",
|
|
69
|
+
required: true,
|
|
70
|
+
inputType: "email",
|
|
71
|
+
autoComplete: "email",
|
|
72
|
+
placeholder: "username@mail.com",
|
|
73
|
+
requiredMessage: "Please enter a valid email address"
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
function fieldTypeSpec(type) {
|
|
77
|
+
return FORM_FIELD_TYPES[type];
|
|
78
|
+
}
|
|
79
|
+
function identityValidator(name) {
|
|
80
|
+
const field = BUILT_IN_BOOKING_FIELDS.find((f) => f.name === name);
|
|
81
|
+
if (!field) throw new Error(`Unknown built-in booking field: ${name}`);
|
|
82
|
+
const base = z.string().max(255);
|
|
83
|
+
return field.inputType === "email" ? base.email({ message: field.requiredMessage }) : base.min(1, { message: field.requiredMessage });
|
|
15
84
|
}
|
|
16
85
|
var IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+)$/;
|
|
17
86
|
function isValidIanaTimezone(tz) {
|
|
@@ -34,34 +103,14 @@ function buildBookingSchema(formFields, legalConsent, slot) {
|
|
|
34
103
|
const answers = {};
|
|
35
104
|
for (const field of formFields) {
|
|
36
105
|
if (!isSupportedFormField(field)) continue;
|
|
106
|
+
const spec = FORM_FIELD_TYPES[field.type];
|
|
37
107
|
let validator;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
validator = z.boolean();
|
|
41
|
-
break;
|
|
42
|
-
case "select":
|
|
43
|
-
case "radio":
|
|
44
|
-
validator = z.string().refine((v) => !v || (field.options ?? []).includes(v), {
|
|
45
|
-
message: `Please choose a valid option for ${field.label}`
|
|
46
|
-
});
|
|
47
|
-
break;
|
|
48
|
-
case "textarea":
|
|
49
|
-
validator = z.string().max(5e3, { message: `${field.label} is too long` });
|
|
50
|
-
break;
|
|
51
|
-
case "number":
|
|
52
|
-
validator = z.string().max(32, { message: `${field.label} is too long` }).regex(/^-?\d+(\.\d+)?$/, { message: `${field.label} must be a number` });
|
|
53
|
-
break;
|
|
54
|
-
case "text":
|
|
55
|
-
default:
|
|
56
|
-
validator = z.string().max(1e3, { message: `${field.label} is too long` });
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
if (field.required) {
|
|
60
|
-
validator = field.type === "checkbox" ? z.literal(true, { message: `${field.label} is required` }) : validator.min(1, { message: `${field.label} is required` });
|
|
61
|
-
} else if (field.type !== "checkbox") {
|
|
62
|
-
validator = validator.optional().or(z.literal(""));
|
|
108
|
+
if (spec.kind === "boolean") {
|
|
109
|
+
validator = field.required ? z.literal(true, { message: `${field.label} is required` }) : z.boolean().optional();
|
|
63
110
|
} else {
|
|
64
|
-
|
|
111
|
+
const base = field.required ? z.string().min(1, { message: `${field.label} is required` }) : z.string();
|
|
112
|
+
validator = spec.validator(field, base);
|
|
113
|
+
if (!field.required) validator = validator.optional().or(z.literal(""));
|
|
65
114
|
}
|
|
66
115
|
answers[field.name] = validator;
|
|
67
116
|
}
|
|
@@ -72,9 +121,11 @@ function buildBookingSchema(formFields, legalConsent, slot) {
|
|
|
72
121
|
meetingId: z.string().min(1),
|
|
73
122
|
startTimeMs: slot.startTimeMs,
|
|
74
123
|
durationMs: slot.durationMs,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
124
|
+
// The identity trio's rules come from BUILT_IN_BOOKING_FIELDS — the
|
|
125
|
+
// keys stay literal so the payload type keeps its named properties.
|
|
126
|
+
firstName: identityValidator("firstName"),
|
|
127
|
+
lastName: identityValidator("lastName"),
|
|
128
|
+
email: identityValidator("email"),
|
|
78
129
|
timezone: slot.timezone,
|
|
79
130
|
locale: z.string().refine(isValidBcp47Locale, { message: "Invalid locale" }).optional(),
|
|
80
131
|
// Plain `.optional()` (no `.default()`) so zod's input and output types
|
|
@@ -112,6 +163,6 @@ function makeDeferredBookingSchema(formFields, legalConsent) {
|
|
|
112
163
|
});
|
|
113
164
|
}
|
|
114
165
|
|
|
115
|
-
export { MEETING_BOOKING_ERROR_CODES, SUPPORTED_FORM_FIELD_TYPES, isSupportedFormField, isValidBcp47Locale, isValidIanaTimezone, makeBookingSchema, makeDeferredBookingSchema };
|
|
166
|
+
export { BUILT_IN_BOOKING_FIELDS, FORM_FIELD_TYPES, FORM_FIELD_TYPES_WITH_OPTIONS, MEETING_BOOKING_ERROR_CODES, SUPPORTED_FORM_FIELD_TYPES, fieldTypeSpec, isSupportedFormField, isValidBcp47Locale, isValidIanaTimezone, makeBookingSchema, makeDeferredBookingSchema };
|
|
116
167
|
//# sourceMappingURL=meeting-booking-schema.js.map
|
|
117
168
|
//# sourceMappingURL=meeting-booking-schema.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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","file":"meeting-booking-schema.js","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * HubSpot custom-question types the native form supports. The widget's\n * renderer switches over THIS set and `makeBookingSchema` maps over THIS set;\n * fail-closed = a field whose `type` is not in the set (the widget then\n * renders the \"Open in HubSpot\" escape hatch for that link instead of a\n * half-working native form). Exact upstream type strings are pinned against\n * the rollout fixture link — extend here (renderer + validator move together).\n */\n// HubSpot `fieldType` values the widget can render faithfully. Anything else\n// (phonenumber, date, booleancheckbox, file, …) makes the link \"not natively\n// bookable\" and the card fails closed to the HubSpot escape hatch — so adding a\n// type here means adding a control in `booking-form.tsx` AND a validator below.\n// `number` is a Number property: the wire value stays a STRING like every other\n// answer (HubSpot's book endpoint takes `{ name, value: string }`), validated as\n// a decimal literal.\nexport const SUPPORTED_FORM_FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox', 'number'] as const;\nexport type SupportedFormFieldType = (typeof SUPPORTED_FORM_FIELD_TYPES)[number];\n\nexport function isSupportedFormField(field: MeetingFormField): boolean {\n return (SUPPORTED_FORM_FIELD_TYPES as readonly string[]).includes(field.type);\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nfunction buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.ZodTypeAny, TZone extends z.ZodTypeAny>(\n formFields: MeetingFormField[],\n legalConsent: MeetingLegalConsent | null,\n // Passed IN rather than switched on a boolean: a `deferSlot: boolean`\n // parameter cannot be narrowed at type level, so every ternary inside would\n // infer a union and widen `startTimeMs`/`durationMs` to `number | null |\n // undefined` for BOTH schemas — including the server's wire type.\n slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },\n) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n let validator: z.ZodTypeAny;\n switch (field.type as SupportedFormFieldType) {\n case 'checkbox':\n validator = z.boolean();\n break;\n case 'select':\n case 'radio':\n validator = z.string().refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n break;\n case 'textarea':\n validator = z.string().max(5000, { message: `${field.label} is too long` });\n break;\n case 'number':\n // Optional integer sign, digits, optional decimal part — what\n // `<input type=\"number\">` emits and what a Number property stores.\n validator = z\n .string()\n .max(32, { message: `${field.label} is too long` })\n .regex(/^-?\\d+(\\.\\d+)?$/, { message: `${field.label} must be a number` });\n break;\n case 'text':\n default:\n validator = z.string().max(1000, { message: `${field.label} is too long` });\n break;\n }\n if (field.required) {\n validator =\n field.type === 'checkbox'\n ? z.literal(true, { message: `${field.label} is required` })\n : (validator as z.ZodString).min(1, { message: `${field.label} is required` });\n } else if (field.type !== 'checkbox') {\n validator = (validator as z.ZodString).optional().or(z.literal(''));\n } else {\n validator = z.boolean().optional();\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: slot.startTimeMs,\n durationMs: slot.durationMs,\n firstName: z.string().min(1, { message: 'First name is required' }).max(255),\n lastName: z.string().min(1, { message: 'Last name is required' }).max(255),\n email: z.string().email({ message: 'Please enter a valid email address' }).max(255),\n timezone: slot.timezone,\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\n/**\n * The STRICT schema — the wire contract. The server rebuilds it from the link's\n * own fetched metadata and validates every booking against it; a slot and a\n * duration are not optional on anything that reaches HubSpot.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n });\n}\n\n/**\n * The DEFERRED schema — same fields, with the slot/duration/timezone triple\n * relaxed. `flow=\"details-first\"` collects answers BEFORE a slot exists, so the\n * form validates against this and the parent re-attaches the authoritative\n * values at POST time.\n *\n * Two named exports over one private builder rather than an overload or an\n * options flag: `MeetingBookingPayload` is `z.infer<ReturnType<...>>`, and\n * either of those alternatives would widen `startTimeMs`/`durationMs` to\n * `number | undefined` for EVERY consumer — including the server's\n * `durationsMs.includes(data.durationMs)`.\n */\nexport function makeDeferredBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n // `.nullish()`, not `.optional()`: the widget passes these straight into\n // `defaultValues`, and details-first has `timezone === null` on the server\n // render and the first client render (zone resolution is post-hydration).\n // Accepting only `undefined` would reject a field with no rendered control\n // and no FieldWrapper — the submit button silently dead, type-check clean.\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: z.number().int().positive().nullish(),\n durationMs: z.number().int().positive().nullish(),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }).nullish(),\n });\n}\n\n/** The wire payload. Pinned to the STRICT builder — see above. */\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n\n/**\n * The form's value type, used as the `useForm` generic in BOTH flows: a relaxed\n * resolver is not assignable to `Resolver<MeetingBookingPayload>`, and mixing\n * the two survives only on TS's bivariant method-parameter check.\n */\nexport type BookingFormValues = z.infer<ReturnType<typeof makeDeferredBookingSchema>>;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/schemas/meeting-booking-schema.ts"],"names":[],"mappings":"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","file":"meeting-booking-schema.js","sourcesContent":["import { z } from 'zod';\n\n/**\n * Meeting-booking wire contracts + validation factory.\n *\n * SERVER-SAFE tsup entry (no \"use client\" banner) with its OWN per-file\n * subpath (`./schemas/meeting-booking-schema`) — same pattern and reasons as\n * `schemas/contact-schema`: used by BOTH the lib's `<HubSpotMeetingScheduler>`\n * (client-side validation) AND the host's server-side booking route, which\n * REBUILDS the schema from the link's own fetched metadata and never trusts a\n * client-shaped instance. zod is an optional peer quarantined to per-subpath\n * verticals — do NOT re-export this module through any broad barrel.\n *\n * Every cross-boundary type for the scheduling feature lives HERE (the lib\n * owns the contract; hosts import this subpath directly, type-only where\n * possible).\n */\n\n// ---------------------------------------------------------------------------\n// Wire types\n// ---------------------------------------------------------------------------\n\n/**\n * Sanitized availability payload served by the host proxy\n * (`GET {apiBaseUrl}/api/meetings/availability?meeting=<id>&monthOffset=<n>`).\n *\n * NO timezone field on purpose: slot starts are absolute epoch-ms instants\n * (verified timezone-independent against the live API), upstream fetches are\n * UTC-pinned, and ALL zone rendering happens client-side. Slots must be\n * whitelist-copied from HubSpot's `linkAvailability` ONLY — never derived\n * from busy-time data.\n */\nexport interface MeetingAvailability {\n meetingId: string;\n monthOffset: number;\n hasMore: boolean;\n /** Offered durations in ms — HubSpot's native unit for the booking POST. */\n durationsMs: number[];\n /** Bookable slot start times (epoch ms), keyed by duration in ms. */\n slotsByDurationMs: Record<string, number[]>;\n formFields: MeetingFormField[];\n /** Verbatim whitelist-copy of HubSpot's `legalConsentOptions` when consent is enabled; null when disabled. */\n legalConsent: MeetingLegalConsent | null;\n /**\n * Who the visitor is meeting — whitelisted DISPLAY projection the host DAL\n * builds from its own people data (e.g. a profiles table matched\n * server-side). NEVER carries emails or busy-time data; optional so\n * existing hosts stay wire-compatible.\n */\n hosts?: MeetingHost[];\n}\n\n/** Display-only host identity for the scheduler's context panel. */\nexport interface MeetingHost {\n name: string;\n avatarUrl: string | null;\n /** Job title / role line under the name (null → omitted). */\n title: string | null;\n}\n\n/**\n * One scheduling link on the DIRECTORY wire (`GET /api/meetings`) — the\n * host-DAL whitelist projection consumed by `MeetingSchedulerDirectory` and\n * host pages. Never carries organizer emails/busy-time data.\n */\nexport interface SchedulingLink {\n id: string;\n /** The link's public HubSpot booking URL — escape-hatch target only. */\n link: string;\n /** HubSpot slug path — the row's in-app destination is `<basePath>/<slug>`. */\n slug: string;\n /** Audience group key (slugified audience label; `\"other\"` in scope=all). */\n purpose: string;\n title: string;\n description: string | null;\n kind: 'personal' | 'team';\n /** Display-only minutes projection (booking stays ms end-to-end). */\n durationsMinutes: number[];\n hosts: MeetingHost[];\n /** Earliest bookable slot (epoch ms) from the current-month payload. */\n nextAvailableMs: number | null;\n}\n\nexport interface SchedulingLinksPayload {\n purposes: Array<{ purpose: string; label: string; links: SchedulingLink[] }>;\n fetchedAt: string;\n}\n\nexport interface MeetingFormField {\n name: string;\n label: string;\n type: string;\n required: boolean;\n options?: string[];\n}\n\n/**\n * HubSpot's consent copy, rendered VERBATIM by the widget (GDPR surface —\n * never edited, never summarized). Responses are keyed by\n * `communicationTypeId`.\n */\nexport interface MeetingLegalConsent {\n processingConsentText: string;\n processingConsentCheckboxLabel: string | null;\n communicationConsentText: string | null;\n communicationConsentCheckboxes: Array<{\n communicationTypeId: string;\n label: string;\n required: boolean;\n }>;\n privacyPolicyText: string | null;\n isLegitimateInterest: boolean;\n}\n\n/**\n * Whitelisted booking result returned by the host proxy — the THIRD HubSpot\n * payload that reaches a browser, so it gets the same whitelist-copy\n * treatment as the two GETs. Nothing organizer-derived.\n */\nexport interface BookingConfirmation {\n meetingId: string;\n title: string;\n startTimeMs: number;\n durationMs: number;\n}\n\n/**\n * Typed domain errors the booking route emits; the widget keys its recovery\n * UI off these. `SLOT_TAKEN` → refetch-and-recover; `TEMPORARILY_UNAVAILABLE`\n * → retry affordance; `MEETING_UNAVAILABLE` → daily ceiling exhausted\n * (escape hatch, not a retry timer); `LINK_GONE` → link deleted upstream;\n * `INVALID_EMAIL` → HubSpot rejected the attendee address (its\n * MeetingsBookingCreatedError.INVALID_EMAIL class — fake/unreachable\n * mailboxes), so the widget tells the visitor to fix the email instead of\n * blaming the slot or their other details.\n */\nexport const MEETING_BOOKING_ERROR_CODES = [\n 'SLOT_TAKEN',\n 'VALIDATION',\n 'INVALID_EMAIL',\n 'LINK_GONE',\n 'TEMPORARILY_UNAVAILABLE',\n 'MEETING_UNAVAILABLE',\n] as const;\n\n/**\n * Derived from the runtime array ABOVE — the array is the single source of\n * truth. The booking hook validates server codes against it at runtime, so a\n * code added only to a hand-written type would be silently coerced to\n * TEMPORARILY_UNAVAILABLE by every deployed widget (exactly how the\n * INVALID_EMAIL rollout mis-rendered on 2026-08-27: type extended, runtime\n * allowlist stale — an array annotation is not exhaustiveness-checked).\n */\nexport type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[number];\n\n// ---------------------------------------------------------------------------\n// Field-type vocabulary — ONE set drives the renderer AND the validator\n// ---------------------------------------------------------------------------\n\n/**\n * THE registry of HubSpot question types the native form supports — one entry\n * per `fieldType`, and the ONLY place a type is declared. Everything else\n * derives from it: `SupportedFormFieldType` is its key union,\n * `SUPPORTED_FORM_FIELD_TYPES` its keys, `FORM_FIELD_TYPES_WITH_OPTIONS` the\n * entries that carry `options`, and `makeBookingSchema` maps each answer\n * through the entry's validator. The widget's control table\n * (`booking-form.tsx`) is a `Record` over the same key union, so a type added\n * here without a control is a COMPILE error, not a silent gap.\n *\n * Fail-closed: a `fieldType` with no entry makes the link \"not natively\n * bookable\" and the card falls back to the HubSpot escape hatch.\n *\n * Every string type rides the wire as a STRING (HubSpot's book endpoint takes\n * `{ name, value: string }`); `checkbox` is the one boolean. `number` is a\n * Number property validated as a decimal literal — what `<input type=\"number\">`\n * emits and what the property stores.\n */\nexport interface FormFieldTypeSpec {\n /** The answer's wire shape, which also decides how required/optional wraps it. */\n kind: 'string' | 'boolean';\n /** Whether HubSpot publishes `options` for the type (the pickers). */\n hasOptions: boolean;\n /**\n * The validator for ONE answer. String types EXTEND `base`, which already\n * carries the required-ness (`min(1)` when required) — so \"X is required\" is\n * the first issue reported for an empty answer, ahead of the type's own rule.\n * Boolean types ignore it.\n */\n validator: (field: MeetingFormField, base: z.ZodString) => z.ZodTypeAny;\n /** The control's placeholder, derived from the field (the mock's \"Enter Company Name\"). */\n placeholder?: (field: MeetingFormField) => string;\n}\n\nconst optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>\n base.refine(v => !v || (field.options ?? []).includes(v), {\n message: `Please choose a valid option for ${field.label}`,\n });\n\nexport const FORM_FIELD_TYPES = {\n text: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(1000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter ${field.label}`,\n },\n textarea: {\n kind: 'string',\n hasOptions: false,\n validator: (field, base) => base.max(5000, { message: `${field.label} is too long` }),\n placeholder: field => `Enter text${field.required ? '' : ' (optional)'}`,\n },\n number: {\n kind: 'string',\n hasOptions: false,\n // Canonical decimal literal — the control normalises what the browser\n // accepts (`1e3`, `007`) to this before it is validated or sent.\n validator: (field, base) =>\n base\n .max(32, { message: `${field.label} is too long` })\n .regex(/^-?\\d+(\\.\\d+)?$/, { message: `${field.label} must be a number` }),\n },\n select: { kind: 'string', hasOptions: true, validator: optionValidator },\n radio: { kind: 'string', hasOptions: true, validator: optionValidator },\n checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },\n} as const satisfies Record<string, FormFieldTypeSpec>;\n\nexport type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;\n\nexport const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];\n\n/** The types whose `options` the host must forward (select, radio). */\nexport const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(\n type => FORM_FIELD_TYPES[type].hasOptions,\n);\n\nexport function isSupportedFormField(field: MeetingFormField): field is SupportedMeetingFormField {\n return Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, field.type);\n}\n\n/** A declared question whose `type` is in the registry. */\nexport type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType };\n\n/**\n * The scheduler's fixed identity fields. HubSpot's book endpoint takes them\n * TOP-LEVEL (`firstName`, `lastName`, `email`), its own booking page hardcodes\n * them, and the link's `formFields` never lists them — so they are data HERE,\n * rendered by the widget through the SAME control path as every declared\n * question, rather than three hand-written blocks. `inputType`/`autoComplete`\n * are the browser hints a text control takes; the wire and the validator do\n * not see them.\n */\nexport interface BuiltInBookingField extends MeetingFormField {\n type: 'text';\n required: true;\n inputType?: 'email';\n autoComplete: string;\n placeholder: string;\n /** The wire's own required/format message — kept verbatim from the schema it replaced. */\n requiredMessage: string;\n}\n\nexport const BUILT_IN_BOOKING_FIELDS = [\n {\n name: 'firstName',\n label: 'First Name',\n type: 'text',\n required: true,\n autoComplete: 'given-name',\n placeholder: 'Enter First Name',\n requiredMessage: 'First name is required',\n },\n {\n name: 'lastName',\n label: 'Last Name',\n type: 'text',\n required: true,\n autoComplete: 'family-name',\n placeholder: 'Enter Last Name',\n requiredMessage: 'Last name is required',\n },\n {\n name: 'email',\n label: 'Email',\n type: 'text',\n required: true,\n inputType: 'email',\n autoComplete: 'email',\n placeholder: 'username@mail.com',\n requiredMessage: 'Please enter a valid email address',\n },\n] as const satisfies readonly BuiltInBookingField[];\n\n/** `'firstName' | 'lastName' | 'email'` — derived from the array, never restated. */\nexport type BuiltInBookingFieldName = (typeof BUILT_IN_BOOKING_FIELDS)[number]['name'];\n\n/** The registry entry for a supported type, widened to the spec so optional\n * members (`placeholder`) are readable without narrowing on the union. */\nexport function fieldTypeSpec(type: SupportedFormFieldType): FormFieldTypeSpec {\n return FORM_FIELD_TYPES[type];\n}\n\n/** The wire validator for one identity field, from ITS declaration above. */\nfunction identityValidator(name: BuiltInBookingFieldName) {\n const field: BuiltInBookingField | undefined = BUILT_IN_BOOKING_FIELDS.find(f => f.name === name);\n if (!field) throw new Error(`Unknown built-in booking field: ${name}`);\n const base = z.string().max(255);\n return field.inputType === 'email'\n ? base.email({ message: field.requiredMessage })\n : base.min(1, { message: field.requiredMessage });\n}\n\n// ---------------------------------------------------------------------------\n// Validators (single home — client widget and server rebuild both use these)\n// ---------------------------------------------------------------------------\n\nconst IANA_TZ_RE = /^(?:UTC|[A-Za-z_]+(?:\\/[A-Za-z0-9_+-]+)+)$/;\n\n/**\n * IANA timezone check. Shape prefilter, then the authoritative resolution\n * test: `Intl.DateTimeFormat` throws on unknown zones. NOT\n * `Intl.supportedValuesOf('timeZone')` — that list excludes `'UTC'` itself\n * (verified in Node), which is a legitimate booking zone. Reject, never coerce.\n */\nexport function isValidIanaTimezone(tz: string): boolean {\n if (!IANA_TZ_RE.test(tz)) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n/** BCP-47 locale shape check via `Intl.getCanonicalLocales`. Reject, never coerce. */\nexport function isValidBcp47Locale(locale: string): boolean {\n try {\n return Intl.getCanonicalLocales(locale).length > 0;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Schema factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the booking-form schema for ONE link's declared questions + consent.\n *\n * A factory (not a static schema) because per-link required questions cannot\n * be expressed statically. The widget builds it from the availability payload\n * it rendered; the server REBUILDS it from the link's own fetched metadata —\n * required-consent enforcement flows from this rebuild, not a parallel check.\n *\n * Deliberately NOT `.strict()`: the humanity-signal fields\n * (`HUMANITY_SIGNAL_KEYS` from `utils/humanity-signals`) ride alongside in\n * the same POST body (read raw by the host's bot gate BEFORE parsing) and are\n * stripped server-side before anything reaches HubSpot. zod's default\n * unknown-key stripping means the parsed output never contains them.\n *\n * `timezone`/`locale` are POST-only presentation fields (the invite renders\n * in the visitor's local time) — this schema is the ONLY place a\n * client-supplied zone is accepted; the availability path is UTC-pinned.\n */\nfunction buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.ZodTypeAny, TZone extends z.ZodTypeAny>(\n formFields: MeetingFormField[],\n legalConsent: MeetingLegalConsent | null,\n // Passed IN rather than switched on a boolean: a `deferSlot: boolean`\n // parameter cannot be narrowed at type level, so every ternary inside would\n // infer a union and widen `startTimeMs`/`durationMs` to `number | null |\n // undefined` for BOTH schemas — including the server's wire type.\n slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },\n) {\n const answers: Record<string, z.ZodTypeAny> = {};\n for (const field of formFields) {\n if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time\n const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];\n // required/optional wrapping follows the answer's wire KIND, not its type:\n // a boolean is true-or-absent, a string is non-empty-or-empty. For strings\n // the required check is the FIRST rule on the chain, so an empty answer\n // reports \"is required\" rather than the type's own message.\n let validator: z.ZodTypeAny;\n if (spec.kind === 'boolean') {\n validator = field.required ? z.literal(true, { message: `${field.label} is required` }) : z.boolean().optional();\n } else {\n const base = field.required ? z.string().min(1, { message: `${field.label} is required` }) : z.string();\n validator = spec.validator(field, base);\n if (!field.required) validator = validator.optional().or(z.literal(''));\n }\n answers[field.name] = validator;\n }\n\n const requiredConsentIds = (legalConsent?.communicationConsentCheckboxes ?? [])\n .filter(c => c.required)\n .map(c => c.communicationTypeId);\n\n // A required answer cannot be enforced by an `.optional()` parent object —\n // omitting the `formFields` key entirely would skip every per-question\n // rule. When the link declares at least one required supported question,\n // the object itself is required.\n const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);\n const answersObject = z.object(answers);\n\n return (\n z\n .object({\n meetingId: z.string().min(1),\n startTimeMs: slot.startTimeMs,\n durationMs: slot.durationMs,\n // The identity trio's rules come from BUILT_IN_BOOKING_FIELDS — the\n // keys stay literal so the payload type keeps its named properties.\n firstName: identityValidator('firstName'),\n lastName: identityValidator('lastName'),\n email: identityValidator('email'),\n timezone: slot.timezone,\n locale: z.string().refine(isValidBcp47Locale, { message: 'Invalid locale' }).optional(),\n // Plain `.optional()` (no `.default()`) so zod's input and output types\n // match — react-hook-form's zodResolver needs them identical, and the\n // server handles `undefined` explicitly anyway. REQUIRED when the link\n // declares required questions (see `hasRequiredAnswers` above).\n formFields: hasRequiredAnswers ? answersObject : answersObject.optional(),\n legalConsentResponses: z\n .array(z.object({ communicationTypeId: z.string(), consented: z.boolean() }))\n .optional(),\n })\n // Object-level rule: `.refine` on an `.optional()` field is skipped when\n // the field is absent — required consents must reject even on a payload\n // that omits the array entirely.\n .superRefine((data, ctx) => {\n const responses = data.legalConsentResponses ?? [];\n const ok = requiredConsentIds.every(id =>\n responses.some(r => r.communicationTypeId === id && r.consented === true),\n );\n if (!ok) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['legalConsentResponses'],\n message: 'Required consent checkboxes must be accepted',\n });\n }\n })\n );\n}\n\n/**\n * The STRICT schema — the wire contract. The server rebuilds it from the link's\n * own fetched metadata and validates every booking against it; a slot and a\n * duration are not optional on anything that reaches HubSpot.\n */\nexport function makeBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: z.number().int().positive(),\n durationMs: z.number().int().positive(),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }),\n });\n}\n\n/**\n * The DEFERRED schema — same fields, with the slot/duration/timezone triple\n * relaxed. `flow=\"details-first\"` collects answers BEFORE a slot exists, so the\n * form validates against this and the parent re-attaches the authoritative\n * values at POST time.\n *\n * Two named exports over one private builder rather than an overload or an\n * options flag: `MeetingBookingPayload` is `z.infer<ReturnType<...>>`, and\n * either of those alternatives would widen `startTimeMs`/`durationMs` to\n * `number | undefined` for EVERY consumer — including the server's\n * `durationsMs.includes(data.durationMs)`.\n */\nexport function makeDeferredBookingSchema(formFields: MeetingFormField[], legalConsent: MeetingLegalConsent | null) {\n // `.nullish()`, not `.optional()`: the widget passes these straight into\n // `defaultValues`, and details-first has `timezone === null` on the server\n // render and the first client render (zone resolution is post-hydration).\n // Accepting only `undefined` would reject a field with no rendered control\n // and no FieldWrapper — the submit button silently dead, type-check clean.\n return buildBookingSchema(formFields, legalConsent, {\n startTimeMs: z.number().int().positive().nullish(),\n durationMs: z.number().int().positive().nullish(),\n timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid timezone' }).nullish(),\n });\n}\n\n/** The wire payload. Pinned to the STRICT builder — see above. */\nexport type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;\n\n/**\n * The form's value type, used as the `useForm` generic in BOTH flows: a relaxed\n * resolver is not assignable to `Resolver<MeetingBookingPayload>`, and mixing\n * the two survives only on TS's bivariant method-parameter check.\n */\nexport type BookingFormValues = z.infer<ReturnType<typeof makeDeferredBookingSchema>>;\n"]}
|
package/package.json
CHANGED
|
@@ -2,18 +2,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
|
2
2
|
import { createRef } from 'react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { BookingForm } from '../components/meeting-scheduler/booking-form';
|
|
5
|
-
import
|
|
5
|
+
import { availabilityWith } from './fixtures/meeting-booking';
|
|
6
6
|
|
|
7
|
-
const availability
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
hasMore: false,
|
|
11
|
-
durationsMs: [1_800_000],
|
|
12
|
-
slotsByDurationMs: { '1800000': [1_700_000_000_000] },
|
|
13
|
-
formFields: [{ name: 'number_of_endpoints', label: 'Number of endpoints', type: 'number', required: true }],
|
|
14
|
-
legalConsent: null,
|
|
15
|
-
hosts: [],
|
|
16
|
-
};
|
|
7
|
+
const availability = availabilityWith([
|
|
8
|
+
{ name: 'number_of_endpoints', label: 'Number of endpoints', type: 'number', required: true },
|
|
9
|
+
]);
|
|
17
10
|
|
|
18
11
|
type Submit = (payload: Record<string, unknown>) => Promise<void>;
|
|
19
12
|
|
|
@@ -41,21 +34,33 @@ describe('BookingForm — a HubSpot Number question', () => {
|
|
|
41
34
|
mount();
|
|
42
35
|
const input = screen.getByLabelText(/^Number of endpoints/);
|
|
43
36
|
expect(input).toHaveAttribute('type', 'number');
|
|
44
|
-
expect(input).toHaveAttribute('inputmode', '
|
|
37
|
+
expect(input).toHaveAttribute('inputmode', 'decimal');
|
|
45
38
|
});
|
|
46
39
|
|
|
47
|
-
it('
|
|
40
|
+
it('reports a required question left empty as required, and never calls onSubmit', async () => {
|
|
48
41
|
const onSubmit = mount();
|
|
49
42
|
type(screen.getByLabelText(/^Email/), 'a@b.co');
|
|
50
43
|
type(screen.getByLabelText(/^First Name/), 'A');
|
|
51
44
|
type(screen.getByLabelText(/^Last Name/), 'B');
|
|
52
|
-
// jsdom lets a number input hold arbitrary text; the resolver is the gate.
|
|
53
|
-
type(screen.getByLabelText(/^Number of endpoints/), 'lots');
|
|
54
45
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm Booking' }));
|
|
55
|
-
|
|
46
|
+
// The required rule is FIRST on the chain, so the message is "is required",
|
|
47
|
+
// not the type's own "must be a number".
|
|
48
|
+
expect(await screen.findByText('Number of endpoints is required')).toBeInTheDocument();
|
|
56
49
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
57
50
|
});
|
|
58
51
|
|
|
52
|
+
it('canonicalises what the browser accepts before it reaches the wire', async () => {
|
|
53
|
+
const onSubmit = mount();
|
|
54
|
+
type(screen.getByLabelText(/^Email/), 'a@b.co');
|
|
55
|
+
type(screen.getByLabelText(/^First Name/), 'A');
|
|
56
|
+
type(screen.getByLabelText(/^Last Name/), 'B');
|
|
57
|
+
// `1e3` is a valid number-input value; the wire wants the decimal literal.
|
|
58
|
+
type(screen.getByLabelText(/^Number of endpoints/), '1e3');
|
|
59
|
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm Booking' }));
|
|
60
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
61
|
+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ formFields: { number_of_endpoints: '1000' } }));
|
|
62
|
+
});
|
|
63
|
+
|
|
59
64
|
it('submits a numeric answer as a string under the declared name', async () => {
|
|
60
65
|
const onSubmit = mount();
|
|
61
66
|
type(screen.getByLabelText(/^Email/), 'a@b.co');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MeetingAvailability, MeetingFormField } from '../../schemas/meeting-booking-schema';
|
|
2
|
+
|
|
3
|
+
/** One well-formed slot-first payload minus the questions — the base every
|
|
4
|
+
* booking-schema test extends, declared once. */
|
|
5
|
+
export const BOOKING_BASE = {
|
|
6
|
+
meetingId: '1',
|
|
7
|
+
startTimeMs: 1_700_000_000_000,
|
|
8
|
+
durationMs: 1_800_000,
|
|
9
|
+
firstName: 'Ada',
|
|
10
|
+
lastName: 'Lovelace',
|
|
11
|
+
email: 'ada@example.com',
|
|
12
|
+
timezone: 'UTC',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A minimal availability payload carrying exactly the given questions. */
|
|
16
|
+
export function availabilityWith(formFields: MeetingFormField[]): MeetingAvailability {
|
|
17
|
+
return {
|
|
18
|
+
meetingId: '1',
|
|
19
|
+
monthOffset: 0,
|
|
20
|
+
hasMore: false,
|
|
21
|
+
durationsMs: [1_800_000],
|
|
22
|
+
slotsByDurationMs: { '1800000': [1_700_000_000_000] },
|
|
23
|
+
formFields,
|
|
24
|
+
legalConsent: null,
|
|
25
|
+
hosts: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -5,16 +5,9 @@ import {
|
|
|
5
5
|
makeDeferredBookingSchema,
|
|
6
6
|
type MeetingFormField,
|
|
7
7
|
} from '../schemas/meeting-booking-schema';
|
|
8
|
+
import { BOOKING_BASE } from './fixtures/meeting-booking';
|
|
8
9
|
|
|
9
|
-
const base =
|
|
10
|
-
meetingId: '1',
|
|
11
|
-
startTimeMs: 1_700_000_000_000,
|
|
12
|
-
durationMs: 1_800_000,
|
|
13
|
-
firstName: 'A',
|
|
14
|
-
lastName: 'B',
|
|
15
|
-
email: 'a@b.co',
|
|
16
|
-
timezone: 'America/New_York',
|
|
17
|
-
};
|
|
10
|
+
const base = BOOKING_BASE;
|
|
18
11
|
|
|
19
12
|
const endpoints = (required: boolean): MeetingFormField => ({
|
|
20
13
|
name: 'number_of_endpoints',
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
Label,
|
|
51
51
|
} from '../ui';
|
|
52
52
|
import { HoneypotField } from '../ui/honeypot-field';
|
|
53
|
+
import { RequiredMark } from '../ui/required-mark';
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Fields the caller can suppress. Six values — every primary form
|
|
@@ -288,7 +289,8 @@ export function ContactForm({
|
|
|
288
289
|
{showName && (
|
|
289
290
|
<div className="flex flex-col">
|
|
290
291
|
<Label htmlFor="name">
|
|
291
|
-
Your Name
|
|
292
|
+
Your Name
|
|
293
|
+
<RequiredMark />
|
|
292
294
|
</Label>
|
|
293
295
|
<Input
|
|
294
296
|
id="name"
|
|
@@ -309,7 +311,8 @@ export function ContactForm({
|
|
|
309
311
|
{showEmail && (
|
|
310
312
|
<div className="flex flex-col">
|
|
311
313
|
<Label htmlFor="email">
|
|
312
|
-
Email
|
|
314
|
+
Email
|
|
315
|
+
<RequiredMark />
|
|
313
316
|
</Label>
|
|
314
317
|
<Input
|
|
315
318
|
id="email"
|
|
@@ -402,7 +405,8 @@ export function ContactForm({
|
|
|
402
405
|
{showHelpCategory && (
|
|
403
406
|
<div className="flex flex-col">
|
|
404
407
|
<Label htmlFor="helpCategory">
|
|
405
|
-
Choose your main interest
|
|
408
|
+
Choose your main interest
|
|
409
|
+
<RequiredMark />
|
|
406
410
|
</Label>
|
|
407
411
|
<Controller
|
|
408
412
|
control={control}
|
|
@@ -437,7 +441,8 @@ export function ContactForm({
|
|
|
437
441
|
{showMessage && (
|
|
438
442
|
<div className="flex flex-grow flex-col">
|
|
439
443
|
<Label htmlFor="message">
|
|
440
|
-
Your Message
|
|
444
|
+
Your Message
|
|
445
|
+
<RequiredMark />
|
|
441
446
|
</Label>
|
|
442
447
|
<Textarea
|
|
443
448
|
id="message"
|