@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.
@@ -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 id={id} aria-required={field.required || undefined} aria-invalid={Boolean(error)}>
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
- <FieldWrapper key={field.name} label={field.label} htmlFor={where.id} required={field.required} error={where.error}>
551
- {FIELD_CONTROLS[field.type]({
552
- field,
553
- id: where.id,
554
- registerName: where.registerName,
555
- error: where.error,
556
- register,
557
- control,
558
- })}
559
- </FieldWrapper>
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` and a host consent row,
151
- * spread onto whichever form renders. Serialisable, so a Server Component
152
- * can pass it across the RSC boundary where a component cannot.
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