@flamingo-stack/openframe-frontend-core 0.0.676-snapshot.20260928181831 → 0.0.676

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, typeInto } from './fixtures/meeting-booking';
9
+ import { availabilityWith, fillIdentity } 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,45 +164,4 @@ 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
- });
208
167
  });
@@ -6,16 +6,12 @@ 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';
10
9
  import {
11
10
  BUILT_IN_BOOKING_FIELDS,
12
11
  type BuiltInBookingFieldName,
13
12
  DECIMAL_LITERAL_RE,
14
13
  fieldTypeSpec,
15
14
  makeDeferredBookingSchema,
16
- withDeniedEmailDomains,
17
- emailDomainDenied,
18
- type DeniedEmailDomains,
19
15
  MULTI_VALUE_SEPARATOR,
20
16
  normalizeFormFields,
21
17
  splitMultiValue,
@@ -64,21 +60,6 @@ export type BookingFieldSpan = keyof typeof SPAN_CLASS;
64
60
 
65
61
  export type BookingFieldRow = BookingFieldSlot[];
66
62
 
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
-
82
63
  /**
83
64
  * A HOST-supplied consent row — the block the waitlist form draws for its SMS
84
65
  * consent, here for "I agree to the Privacy Policy and to be contacted". It is
@@ -201,9 +182,7 @@ const canonicalNumber = (v: unknown): string => {
201
182
  * validator and the renderer can never disagree about what is supported.
202
183
  * Every control states `aria-invalid` from the field's message and carries
203
184
  * `required`/`aria-required` from the field, so the accent asterisk is never
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.
185
+ * the only signal.
207
186
  */
208
187
  const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => ReactNode> = {
209
188
  text: ({ field, id, registerName, error, register }) => (
@@ -212,7 +191,6 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
212
191
  type={field.inputType ?? 'text'}
213
192
  required={field.required}
214
193
  aria-invalid={Boolean(error)}
215
- invalid={Boolean(error)}
216
194
  autoComplete={field.autoComplete}
217
195
  placeholder={placeholderFor(field)}
218
196
  {...register(registerName as never)}
@@ -223,7 +201,6 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
223
201
  id={id}
224
202
  required={field.required}
225
203
  aria-invalid={Boolean(error)}
226
- invalid={Boolean(error)}
227
204
  placeholder={placeholderFor(field)}
228
205
  {...register(registerName as never)}
229
206
  />
@@ -236,7 +213,6 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
236
213
  step="any"
237
214
  required={field.required}
238
215
  aria-invalid={Boolean(error)}
239
- invalid={Boolean(error)}
240
216
  {...register(registerName as never, { setValueAs: canonicalNumber })}
241
217
  />
242
218
  ),
@@ -248,7 +224,6 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
248
224
  autoComplete="tel"
249
225
  required={field.required}
250
226
  aria-invalid={Boolean(error)}
251
- invalid={Boolean(error)}
252
227
  placeholder={placeholderFor(field)}
253
228
  {...register(registerName as never, { setValueAs: trimmed })}
254
229
  />
@@ -260,7 +235,6 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
260
235
  type="date"
261
236
  required={field.required}
262
237
  aria-invalid={Boolean(error)}
263
- invalid={Boolean(error)}
264
238
  {...register(registerName as never)}
265
239
  />
266
240
  ),
@@ -270,12 +244,7 @@ const FIELD_CONTROLS: Record<SupportedFormFieldType, (args: ControlArgs) => Reac
270
244
  name={registerName as never}
271
245
  render={({ field: rhf }) => (
272
246
  <Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
273
- <SelectTrigger
274
- id={id}
275
- aria-required={field.required || undefined}
276
- aria-invalid={Boolean(error)}
277
- invalid={Boolean(error)}
278
- >
247
+ <SelectTrigger id={id} aria-required={field.required || undefined} aria-invalid={Boolean(error)}>
279
248
  <SelectValue placeholder="Select…" />
280
249
  </SelectTrigger>
281
250
  <SelectContent>
@@ -396,11 +365,6 @@ export interface BookingFormProps {
396
365
  * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
397
366
  */
398
367
  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;
404
368
  /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */
405
369
  consent?: BookingFormConsent;
406
370
  isSubmitting: boolean;
@@ -440,8 +404,6 @@ export function BookingForm({
440
404
  submitLabel,
441
405
  footerNote,
442
406
  fieldRows,
443
- fieldCopy,
444
- deniedEmailDomains,
445
407
  consent,
446
408
  isSubmitting,
447
409
  onSubmit,
@@ -458,8 +420,8 @@ export function BookingForm({
458
420
  // resolver is not assignable to `Resolver<BookingFormValues>`. The strict
459
421
  // schema is the server's contract — see `makeBookingSchema`'s docblock.
460
422
  const schema = useMemo(
461
- () => withDeniedEmailDomains(makeDeferredBookingSchema(supportedFields, legalConsent), deniedEmailDomains),
462
- [supportedFields, legalConsent, deniedEmailDomains],
423
+ () => makeDeferredBookingSchema(supportedFields, legalConsent),
424
+ [supportedFields, legalConsent],
463
425
  );
464
426
 
465
427
  const consentDefaults = useMemo(
@@ -544,28 +506,6 @@ export function BookingForm({
544
506
  },
545
507
  );
546
508
 
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
-
569
509
  const submitValid = handleSubmit(async data => {
570
510
  if (consentMissing) return; // the error is already on screen — see `submit`
571
511
  if (deferSlot) {
@@ -586,7 +526,7 @@ export function BookingForm({
586
526
  ...getSignals(),
587
527
  ...formRescue.submitFields(),
588
528
  });
589
- }, onInvalid);
529
+ });
590
530
 
591
531
  // Consent is checked BEFORE the resolver runs, not inside the valid branch,
592
532
  // so an unticked box and an empty field are reported together rather than
@@ -606,32 +546,18 @@ export function BookingForm({
606
546
  const renderField = (
607
547
  field: ControlArgs['field'],
608
548
  where: { id: string; registerName: string; error?: string },
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
- };
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
+ );
635
561
 
636
562
  const builtInFields: Record<string, ReactNode> = Object.fromEntries(
637
563
  BUILT_IN_BOOKING_FIELDS.map(field => [
@@ -147,12 +147,11 @@ export interface HubSpotMeetingSchedulerProps {
147
147
  */
148
148
  detailsForm?: ComponentType<BookingFormProps>;
149
149
  /**
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.
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.
154
153
  */
155
- detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent' | 'fieldCopy' | 'deniedEmailDomains'>;
154
+ detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;
156
155
  /** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:
157
156
  * omitted or `null` saves nothing. */
158
157
  rescue?: FormRescueDefinition | null;
@@ -987,7 +986,6 @@ export {
987
986
  type BookingFormProps,
988
987
  type BookingFieldRow,
989
988
  type BookingFieldSlot,
990
- type BookingFieldCopy,
991
989
  type BookingFormConsent,
992
990
  } from './booking-form';
993
991
 
@@ -997,4 +995,4 @@ export {
997
995
  type MeetingSchedulerDirectoryProps,
998
996
  } from './directory';
999
997
  export type { MeetingAvailability, BookingConfirmation, MeetingBookingErrorCode, MeetingHost };
1000
- export type { SchedulingLink, SchedulingLinksPayload, DeniedEmailDomains } from '../../schemas/meeting-booking-schema';
998
+ export type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema';
@@ -699,40 +699,6 @@ 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
-
736
702
  /** The wire payload. Pinned to the STRICT builder — see above. */
737
703
  export type MeetingBookingPayload = z.infer<ReturnType<typeof makeBookingSchema>>;
738
704