@flamingo-stack/openframe-frontend-core 0.0.676 → 0.0.677
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 +21 -2
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +92 -38
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts +7 -6
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +66 -12
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/schemas/meeting-booking-schema.cjs +16 -0
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +34 -0
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +15 -1
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/meeting-scheduler-details-first.test.tsx +42 -1
- package/src/components/meeting-scheduler/booking-form.tsx +91 -17
- package/src/components/meeting-scheduler/index.tsx +7 -5
- package/src/schemas/meeting-booking-schema.ts +34 -0
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type SchedulerStage,
|
|
7
7
|
} from '../components/meeting-scheduler';
|
|
8
8
|
import type { MeetingAvailability } from '../schemas/meeting-booking-schema';
|
|
9
|
-
import { availabilityWith, fillIdentity } from './fixtures/meeting-booking';
|
|
9
|
+
import { availabilityWith, fillIdentity, typeInto } from './fixtures/meeting-booking';
|
|
10
10
|
|
|
11
11
|
/** A slot two hours from now, on the hour — inside the month the calendar opens on. */
|
|
12
12
|
const SLOT_MS = Math.ceil((Date.now() + 2 * 3_600_000) / 3_600_000) * 3_600_000;
|
|
@@ -164,4 +164,45 @@ describe('HubSpotMeetingScheduler — which links fall back to HubSpot', () => {
|
|
|
164
164
|
expect(screen.queryByText(BOOKED_ON_HUBSPOT)).not.toBeInTheDocument();
|
|
165
165
|
expect(screen.queryByLabelText(/^Upload/)).not.toBeInTheDocument();
|
|
166
166
|
});
|
|
167
|
+
it('fieldCopy replaces a field label and placeholder, and derives the one it is not given', async () => {
|
|
168
|
+
availability.formFields = [];
|
|
169
|
+
render(
|
|
170
|
+
scheduler({
|
|
171
|
+
detailsFormProps: {
|
|
172
|
+
fieldCopy: {
|
|
173
|
+
email: { label: 'Business Email', placeholder: 'username@company.com' },
|
|
174
|
+
firstName: { label: 'Given Name' },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
const email = await screen.findByLabelText(/^Business Email/);
|
|
180
|
+
expect(email).toHaveAttribute('placeholder', 'username@company.com');
|
|
181
|
+
// A label alone still moves the placeholder the TYPE derives from it.
|
|
182
|
+
expect(screen.getByLabelText(/^Given Name/)).toHaveAttribute('placeholder', 'Enter Given Name');
|
|
183
|
+
// The overridden field keeps everything else: its own label is gone, and the
|
|
184
|
+
// untouched one is drawn exactly as the widget declares it.
|
|
185
|
+
expect(screen.queryByLabelText(/^Email/)).not.toBeInTheDocument();
|
|
186
|
+
expect(screen.getByLabelText(/^Last Name/)).toHaveAttribute('placeholder', 'Enter Last Name');
|
|
187
|
+
});
|
|
188
|
+
it('a denied email domain keeps the visitor on the form and says why', async () => {
|
|
189
|
+
availability.formFields = [];
|
|
190
|
+
const message = 'Looks like a personal email. Drop your work email instead.';
|
|
191
|
+
render(scheduler({ detailsFormProps: { deniedEmailDomains: { domains: ['gmail.com'], message } } }));
|
|
192
|
+
await screen.findByLabelText(/^Email/);
|
|
193
|
+
fillIdentity();
|
|
194
|
+
typeInto(screen.getByLabelText(/^Email/), 'ada@gmail.com');
|
|
195
|
+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
|
196
|
+
// The message lands under the email control, and the form does not advance
|
|
197
|
+
// (Back exists on the calendar step only).
|
|
198
|
+
expect(await screen.findByText(message)).toBeInTheDocument();
|
|
199
|
+
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
|
|
200
|
+
// …and once at the button, where a visitor whose address LOOKS answered is
|
|
201
|
+
// looking when nothing happens.
|
|
202
|
+
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ description: message, variant: 'error' }));
|
|
203
|
+
// A work address passes the same form.
|
|
204
|
+
typeInto(screen.getByLabelText(/^Email/), 'ada@acmecorp.com');
|
|
205
|
+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
|
206
|
+
expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument();
|
|
207
|
+
});
|
|
167
208
|
});
|
|
@@ -6,12 +6,16 @@ import type { FormEvent, ReactNode, Ref } from 'react';
|
|
|
6
6
|
import { useForm, Controller } from 'react-hook-form';
|
|
7
7
|
import type { Control, Path, UseFormRegister } from 'react-hook-form';
|
|
8
8
|
import { useRescuedForm } from '../../hooks/use-rescued-form';
|
|
9
|
+
import { useToast } from '../../hooks/use-toast';
|
|
9
10
|
import {
|
|
10
11
|
BUILT_IN_BOOKING_FIELDS,
|
|
11
12
|
type BuiltInBookingFieldName,
|
|
12
13
|
DECIMAL_LITERAL_RE,
|
|
13
14
|
fieldTypeSpec,
|
|
14
15
|
makeDeferredBookingSchema,
|
|
16
|
+
withDeniedEmailDomains,
|
|
17
|
+
emailDomainDenied,
|
|
18
|
+
type DeniedEmailDomains,
|
|
15
19
|
MULTI_VALUE_SEPARATOR,
|
|
16
20
|
normalizeFormFields,
|
|
17
21
|
splitMultiValue,
|
|
@@ -60,6 +64,21 @@ export type BookingFieldSpan = keyof typeof SPAN_CLASS;
|
|
|
60
64
|
|
|
61
65
|
export type BookingFieldRow = BookingFieldSlot[];
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Display copy a HOST overrides for ONE field, keyed by its name — a built-in
|
|
69
|
+
* (`email`) or a HubSpot-declared question. It replaces what is DRAWN and
|
|
70
|
+
* nothing else: the name the answer rides under, the validation and the
|
|
71
|
+
* schema's own messages are untouched, so a campaign page can ask for a
|
|
72
|
+
* "Business Email" without owning a second email rule.
|
|
73
|
+
*
|
|
74
|
+
* An overridden `label` also feeds the type's derived placeholder
|
|
75
|
+
* (`Enter <label>`), so overriding the label alone keeps the pair consistent.
|
|
76
|
+
*/
|
|
77
|
+
export interface BookingFieldCopy {
|
|
78
|
+
label?: string;
|
|
79
|
+
placeholder?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
/**
|
|
64
83
|
* A HOST-supplied consent row — the block the waitlist form draws for its SMS
|
|
65
84
|
* consent, here for "I agree to the Privacy Policy and to be contacted". It is
|
|
@@ -182,7 +201,9 @@ const canonicalNumber = (v: unknown): string => {
|
|
|
182
201
|
* validator and the renderer can never disagree about what is supported.
|
|
183
202
|
* Every control states `aria-invalid` from the field's message and carries
|
|
184
203
|
* `required`/`aria-required` from the field, so the accent asterisk is never
|
|
185
|
-
* the only signal.
|
|
204
|
+
* the only signal. The ones that can PAINT the state take `invalid` too
|
|
205
|
+
* (`Input`, `Textarea`, `SelectTrigger` — the lib's error border), so a refused
|
|
206
|
+
* field reads as refused at the control, not only in the line beneath it.
|
|
186
207
|
*/
|
|
187
208
|
const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => ReactNode> = {
|
|
188
209
|
text: ({ field, id, registerName, error, register }) => (
|
|
@@ -191,6 +212,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
191
212
|
type={field.inputType ?? 'text'}
|
|
192
213
|
required={field.required}
|
|
193
214
|
aria-invalid={Boolean(error)}
|
|
215
|
+
invalid={Boolean(error)}
|
|
194
216
|
autoComplete={field.autoComplete}
|
|
195
217
|
placeholder={placeholderFor(field)}
|
|
196
218
|
{...register(registerName as never)}
|
|
@@ -201,6 +223,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
201
223
|
id={id}
|
|
202
224
|
required={field.required}
|
|
203
225
|
aria-invalid={Boolean(error)}
|
|
226
|
+
invalid={Boolean(error)}
|
|
204
227
|
placeholder={placeholderFor(field)}
|
|
205
228
|
{...register(registerName as never)}
|
|
206
229
|
/>
|
|
@@ -213,6 +236,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
213
236
|
step="any"
|
|
214
237
|
required={field.required}
|
|
215
238
|
aria-invalid={Boolean(error)}
|
|
239
|
+
invalid={Boolean(error)}
|
|
216
240
|
{...register(registerName as never, { setValueAs: canonicalNumber })}
|
|
217
241
|
/>
|
|
218
242
|
),
|
|
@@ -224,6 +248,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
224
248
|
autoComplete="tel"
|
|
225
249
|
required={field.required}
|
|
226
250
|
aria-invalid={Boolean(error)}
|
|
251
|
+
invalid={Boolean(error)}
|
|
227
252
|
placeholder={placeholderFor(field)}
|
|
228
253
|
{...register(registerName as never, { setValueAs: trimmed })}
|
|
229
254
|
/>
|
|
@@ -235,6 +260,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
235
260
|
type="date"
|
|
236
261
|
required={field.required}
|
|
237
262
|
aria-invalid={Boolean(error)}
|
|
263
|
+
invalid={Boolean(error)}
|
|
238
264
|
{...register(registerName as never)}
|
|
239
265
|
/>
|
|
240
266
|
),
|
|
@@ -244,7 +270,12 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
|
|
|
244
270
|
name={registerName as never}
|
|
245
271
|
render={({ field: rhf }) => (
|
|
246
272
|
<Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
|
|
247
|
-
<SelectTrigger
|
|
273
|
+
<SelectTrigger
|
|
274
|
+
id={id}
|
|
275
|
+
aria-required={field.required || undefined}
|
|
276
|
+
aria-invalid={Boolean(error)}
|
|
277
|
+
invalid={Boolean(error)}
|
|
278
|
+
>
|
|
248
279
|
<SelectValue placeholder="Select…" />
|
|
249
280
|
</SelectTrigger>
|
|
250
281
|
<SelectContent>
|
|
@@ -365,6 +396,11 @@ export interface BookingFormProps {
|
|
|
365
396
|
* appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
|
|
366
397
|
*/
|
|
367
398
|
fieldRows?: BookingFieldRow[];
|
|
399
|
+
/** Per-field display overrides, keyed by field name — see `BookingFieldCopy`. */
|
|
400
|
+
fieldCopy?: Record<string, BookingFieldCopy>;
|
|
401
|
+
/** Email domains this form refuses, with the message it shows — see `DeniedEmailDomains`.
|
|
402
|
+
* MEMOIZE it: a new object every render rebuilds the resolver's schema. */
|
|
403
|
+
deniedEmailDomains?: DeniedEmailDomains;
|
|
368
404
|
/** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */
|
|
369
405
|
consent?: BookingFormConsent;
|
|
370
406
|
isSubmitting: boolean;
|
|
@@ -404,6 +440,8 @@ export function BookingForm({
|
|
|
404
440
|
submitLabel,
|
|
405
441
|
footerNote,
|
|
406
442
|
fieldRows,
|
|
443
|
+
fieldCopy,
|
|
444
|
+
deniedEmailDomains,
|
|
407
445
|
consent,
|
|
408
446
|
isSubmitting,
|
|
409
447
|
onSubmit,
|
|
@@ -420,8 +458,8 @@ export function BookingForm({
|
|
|
420
458
|
// resolver is not assignable to `Resolver<BookingFormValues>`. The strict
|
|
421
459
|
// schema is the server's contract — see `makeBookingSchema`'s docblock.
|
|
422
460
|
const schema = useMemo(
|
|
423
|
-
() => makeDeferredBookingSchema(supportedFields, legalConsent),
|
|
424
|
-
[supportedFields, legalConsent],
|
|
461
|
+
() => withDeniedEmailDomains(makeDeferredBookingSchema(supportedFields, legalConsent), deniedEmailDomains),
|
|
462
|
+
[supportedFields, legalConsent, deniedEmailDomains],
|
|
425
463
|
);
|
|
426
464
|
|
|
427
465
|
const consentDefaults = useMemo(
|
|
@@ -506,6 +544,28 @@ export function BookingForm({
|
|
|
506
544
|
},
|
|
507
545
|
);
|
|
508
546
|
|
|
547
|
+
const { toast } = useToast();
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* A refused submit already reports itself under each field, and the button
|
|
551
|
+
* stays live — pressing it is how a visitor asks what is wrong.
|
|
552
|
+
*
|
|
553
|
+
* The denied-domain rule is the one they cannot see coming: the address is
|
|
554
|
+
* well formed and the control looks answered, so the inline line under a
|
|
555
|
+
* filled-in field is easy to miss. That one is ALSO said at the button.
|
|
556
|
+
* Nothing else toasts — a toast per empty field would bury the messages the
|
|
557
|
+
* fields already carry.
|
|
558
|
+
*/
|
|
559
|
+
const onInvalid = () => {
|
|
560
|
+
if (deniedEmailDomains && emailDomainDenied(getValues('email'), deniedEmailDomains)) {
|
|
561
|
+
toast({
|
|
562
|
+
variant: 'error',
|
|
563
|
+
title: 'Check your email address',
|
|
564
|
+
description: deniedEmailDomains.message,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
509
569
|
const submitValid = handleSubmit(async data => {
|
|
510
570
|
if (consentMissing) return; // the error is already on screen — see `submit`
|
|
511
571
|
if (deferSlot) {
|
|
@@ -526,7 +586,7 @@ export function BookingForm({
|
|
|
526
586
|
...getSignals(),
|
|
527
587
|
...formRescue.submitFields(),
|
|
528
588
|
});
|
|
529
|
-
});
|
|
589
|
+
}, onInvalid);
|
|
530
590
|
|
|
531
591
|
// Consent is checked BEFORE the resolver runs, not inside the valid branch,
|
|
532
592
|
// so an unticked box and an empty field are reported together rather than
|
|
@@ -546,18 +606,32 @@ export function BookingForm({
|
|
|
546
606
|
const renderField = (
|
|
547
607
|
field: ControlArgs['field'],
|
|
548
608
|
where: { id: string; registerName: string; error?: string },
|
|
549
|
-
): ReactNode =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
609
|
+
): ReactNode => {
|
|
610
|
+
// DISPLAY only: the host's copy replaces what this control draws, never the
|
|
611
|
+
// name the answer registers under, the validation, or the schema's messages.
|
|
612
|
+
const copy = fieldCopy?.[field.name];
|
|
613
|
+
const shown = copy
|
|
614
|
+
? { ...field, label: copy.label ?? field.label, placeholder: copy.placeholder ?? field.placeholder }
|
|
615
|
+
: field;
|
|
616
|
+
return (
|
|
617
|
+
<FieldWrapper
|
|
618
|
+
key={shown.name}
|
|
619
|
+
label={shown.label}
|
|
620
|
+
htmlFor={where.id}
|
|
621
|
+
required={shown.required}
|
|
622
|
+
error={where.error}
|
|
623
|
+
>
|
|
624
|
+
{FIELD_CONTROLS[shown.type]({
|
|
625
|
+
field: shown,
|
|
626
|
+
id: where.id,
|
|
627
|
+
registerName: where.registerName,
|
|
628
|
+
error: where.error,
|
|
629
|
+
register,
|
|
630
|
+
control,
|
|
631
|
+
})}
|
|
632
|
+
</FieldWrapper>
|
|
633
|
+
);
|
|
634
|
+
};
|
|
561
635
|
|
|
562
636
|
const builtInFields: Record<string, ReactNode> = Object.fromEntries(
|
|
563
637
|
BUILT_IN_BOOKING_FIELDS.map(field => [
|
|
@@ -147,11 +147,12 @@ export interface HubSpotMeetingSchedulerProps {
|
|
|
147
147
|
*/
|
|
148
148
|
detailsForm?: ComponentType<BookingFormProps>;
|
|
149
149
|
/**
|
|
150
|
-
* The DATA form of the same override: `fieldRows
|
|
151
|
-
* spread onto whichever form renders. Serialisable,
|
|
152
|
-
* can pass it across the RSC boundary where a component
|
|
150
|
+
* The DATA form of the same override: `fieldRows`, a host consent row and
|
|
151
|
+
* per-field display copy, spread onto whichever form renders. Serialisable,
|
|
152
|
+
* so a Server Component can pass it across the RSC boundary where a component
|
|
153
|
+
* cannot.
|
|
153
154
|
*/
|
|
154
|
-
detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;
|
|
155
|
+
detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent' | 'fieldCopy' | 'deniedEmailDomains'>;
|
|
155
156
|
/** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:
|
|
156
157
|
* omitted or `null` saves nothing. */
|
|
157
158
|
rescue?: FormRescueDefinition | null;
|
|
@@ -986,6 +987,7 @@ export {
|
|
|
986
987
|
type BookingFormProps,
|
|
987
988
|
type BookingFieldRow,
|
|
988
989
|
type BookingFieldSlot,
|
|
990
|
+
type BookingFieldCopy,
|
|
989
991
|
type BookingFormConsent,
|
|
990
992
|
} from './booking-form';
|
|
991
993
|
|
|
@@ -995,4 +997,4 @@ export {
|
|
|
995
997
|
type MeetingSchedulerDirectoryProps,
|
|
996
998
|
} from './directory';
|
|
997
999
|
export type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost };
|
|
998
|
-
export type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';
|
|
1000
|
+
export type { SchedulingLink, SchedulingLinksPayload, DeniedEmailDomains } from '../../schemas/meeting-booking-schema';
|
|
@@ -699,6 +699,40 @@ export function makeDeferredBookingSchema(formFields: MeetingFormField[], legalC
|
|
|
699
699
|
});
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Email domains a host's form does not accept, with the message it shows when
|
|
704
|
+
* one is typed. The HOST owns the list (its own personal/free-provider rule)
|
|
705
|
+
* and re-checks on its own server — this is the answer at the point of typing,
|
|
706
|
+
* never the gate.
|
|
707
|
+
*/
|
|
708
|
+
export interface DeniedEmailDomains {
|
|
709
|
+
domains: readonly string[];
|
|
710
|
+
message: string;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The address's domain, lower-cased, matched WHOLE against the list — a
|
|
714
|
+
* subdomain is a different domain, which is how a host's own list reads it. */
|
|
715
|
+
export function emailDomainDenied(email: unknown, rule: DeniedEmailDomains): boolean {
|
|
716
|
+
if (typeof email !== 'string') return false;
|
|
717
|
+
const domain = email.split('@')[1]?.trim().toLowerCase();
|
|
718
|
+
if (domain === undefined || domain === '') return false;
|
|
719
|
+
return rule.domains.includes(domain);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** `schema` refined to reject those domains ON the email field, so the message
|
|
723
|
+
* lands under the control the visitor typed in rather than as a form error. */
|
|
724
|
+
export function withDeniedEmailDomains(
|
|
725
|
+
schema: ReturnType<typeof makeDeferredBookingSchema>,
|
|
726
|
+
rule: DeniedEmailDomains | undefined,
|
|
727
|
+
) {
|
|
728
|
+
if (!rule) return schema;
|
|
729
|
+
return schema.superRefine((values, ctx) => {
|
|
730
|
+
if (emailDomainDenied(values.email, rule)) {
|
|
731
|
+
ctx.addIssue({ code: 'custom', path: ['email'], message: rule.message });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
702
736
|
/** The wire payload. Pinned to the STRICT builder — see above. */
|
|
703
737
|
export type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;
|
|
704
738
|
|