@flamingo-stack/openframe-frontend-core 0.0.596-2034.5669.1 → 0.0.596-2034.5671.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.596-2034.5669.1",
3
+ "version": "0.0.596-2034.5671.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { zodResolver } from '@hookform/resolvers/zod';
4
- import { Fragment, useEffect, useMemo } from 'react';
5
- import type { ReactNode, Ref } from 'react';
4
+ import { Fragment, useEffect, useMemo, useState } from 'react';
5
+ import type { FormEvent, ReactNode, Ref } from 'react';
6
6
  import { useForm, Controller } from 'react-hook-form';
7
7
  import {
8
8
  makeDeferredBookingSchema,
@@ -43,6 +43,25 @@ export interface BookingFieldSlot {
43
43
 
44
44
  export type BookingFieldRow = BookingFieldSlot[];
45
45
 
46
+ /**
47
+ * A HOST-supplied consent row — the block the waitlist form draws for its SMS
48
+ * consent, here for "I agree to the Privacy Policy and to be contacted". It is
49
+ * the host's copy and the host's link, so it is a prop, not HubSpot metadata;
50
+ * HubSpot's own `legalConsent` block (verbatim, declared on the link) renders
51
+ * alongside when the link carries one.
52
+ *
53
+ * A client-side gate, exactly like the waitlist's: Continue is refused until it
54
+ * is ticked, and the tick rides in the payload as `hostConsent` — which the
55
+ * host's book route strips as an undeclared key. It is not a HubSpot consent
56
+ * record; declare `legalConsent` on the link when one is required.
57
+ */
58
+ export interface BookingFormConsent {
59
+ label: ReactNode;
60
+ description?: ReactNode;
61
+ /** Shown under the row when Continue is pressed unticked. */
62
+ errorMessage?: string;
63
+ }
64
+
46
65
  /** Static so Tailwind's scanner sees every class — a template built from a
47
66
  * runtime span would compile to nothing. */
48
67
  const SPAN_CLASS: Record<1 | 2 | 3 | 4, string> = {
@@ -101,6 +120,8 @@ export interface BookingFormProps {
101
120
  * appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
102
121
  */
103
122
  fieldRows?: BookingFieldRow[];
123
+ /** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */
124
+ consent?: BookingFormConsent;
104
125
  isSubmitting: boolean;
105
126
  onSubmit: (payload: Record<string, unknown>) => Promise<void>;
106
127
  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
@@ -119,6 +140,7 @@ export function BookingForm({
119
140
  submitLabel,
120
141
  footerNote,
121
142
  fieldRows,
143
+ consent,
122
144
  isSubmitting,
123
145
  onSubmit,
124
146
  honeypotInputProps,
@@ -170,6 +192,12 @@ export function BookingForm({
170
192
 
171
193
  const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;
172
194
 
195
+ // The host consent lives OUTSIDE react-hook-form (it is not a wire field), so
196
+ // a remount restores it from the stash the same way the fields come back.
197
+ const [consented, setConsented] = useState(initialValues?.hostConsent === true);
198
+ const [consentError, setConsentError] = useState<string | null>(null);
199
+ const consentMissing = Boolean(consent) && !consented;
200
+
173
201
  // The seeded availability is refetched immediately on mount, so the link's
174
202
  // consent set can change WHILE this form is open. Reconcile rather than
175
203
  // remount: carry each `consented` across by id and default new ids to false.
@@ -186,17 +214,26 @@ export function BookingForm({
186
214
  );
187
215
  }, [consentDefaults, priorConsents, setValue]);
188
216
 
189
- const submit = handleSubmit(async data => {
217
+ const submitValid = handleSubmit(async data => {
218
+ if (consentMissing) return; // the error is already on screen — see `submit`
190
219
  if (deferSlot) {
191
220
  // Collect-only. Signals are captured HERE, while this form and its
192
221
  // honeypot are still mounted — `getSignals()` reads a detached ref once
193
222
  // they unmount, which would silently disable the decoy.
194
- await onSubmit({ ...data, meetingId });
223
+ await onSubmit({ ...data, meetingId, hostConsent: consented });
195
224
  return;
196
225
  }
197
- await onSubmit({ ...data, meetingId, startTimeMs, durationMs, timezone, ...getSignals() });
226
+ await onSubmit({ ...data, meetingId, hostConsent: consented, startTimeMs, durationMs, timezone, ...getSignals() });
198
227
  });
199
228
 
229
+ // Consent is checked BEFORE the resolver runs, not inside the valid branch,
230
+ // so an unticked box and an empty field are reported together rather than
231
+ // one submit apart.
232
+ const submit = (event: FormEvent<HTMLFormElement>) => {
233
+ if (consentMissing) setConsentError(consent?.errorMessage ?? 'Please agree to continue.');
234
+ return submitValid(event);
235
+ };
236
+
200
237
  const fieldError = (name: string): string | undefined => {
201
238
  const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];
202
239
  return err?.message;
@@ -426,6 +463,21 @@ export function BookingForm({
426
463
  </>
427
464
  )}
428
465
 
466
+ {consent && (
467
+ <CheckboxBlock
468
+ id="ms-host-consent"
469
+ checked={consented}
470
+ onCheckedChange={v => {
471
+ setConsented(v);
472
+ if (v) setConsentError(null);
473
+ }}
474
+ disabled={isSubmitting}
475
+ error={consentError ?? undefined}
476
+ label={consent.label}
477
+ description={consent.description}
478
+ />
479
+ )}
480
+
429
481
  {legalConsent && (
430
482
  // Same out-of-flow message as the fields above, so a missed consent
431
483
  // box doesn't shove the submit button down the card. With no error the
@@ -47,14 +47,6 @@ export interface SchedulerContextPanelProps {
47
47
  * states the length and a live selector beside a filled-in form invites a
48
48
  * change that would throw the form away. Going Back restores them. */
49
49
  locked?: boolean;
50
- /**
51
- * The link's length is not the visitor's to choose here: the static duration
52
- * line renders and the chips never do, at every step. `flow="details-first"`
53
- * sets it — the mock draws "30 min call" beside the host on the calendar step
54
- * and offers no selector — and unlike `locked` it has NO effect on the
55
- * timezone picker, which on that step is the only control the visitor has.
56
- */
57
- fixedDuration?: boolean;
58
50
  /** Whether the timezone picker renders. Default true: the zone governs every
59
51
  * time on screen, the form's summary line included, so it stays reachable
60
52
  * right up to the confirmation — where there is nothing left to re-read. */
@@ -173,7 +165,6 @@ export function SchedulerContextPanel({
173
165
  onBack,
174
166
  backLabel = 'Back',
175
167
  locked = false,
176
- fixedDuration = false,
177
168
  showTimezone = true,
178
169
  className,
179
170
  }: SchedulerContextPanelProps) {
@@ -232,9 +223,8 @@ export function SchedulerContextPanel({
232
223
  </div>
233
224
  )}
234
225
 
235
- {locked || fixedDuration || durationsMs.length <= 1
236
- ? // Post-selection, a fixed-length flow, or a link that offers one
237
- // length: a static line,
226
+ {locked || durationsMs.length <= 1
227
+ ? // Post-selection, or a link that offers one length: a static line,
238
228
  // which is what sits beside the host on the header-strip layouts.
239
229
  selectedDurationMs != null && (
240
230
  <p className="shrink-0 text-ods-text-secondary text-h6">
@@ -249,7 +239,7 @@ export function SchedulerContextPanel({
249
239
 
250
240
  {/* Several lengths on offer: chips, which need a full row of their
251
241
  own at every width — so they stay OUT of the identity row. */}
252
- {!locked && !fixedDuration && durationsMs.length > 1 && (
242
+ {!locked && durationsMs.length > 1 && (
253
243
  <div className="flex flex-col gap-[var(--spacing-system-xxs)]">
254
244
  <p className={FIELD_LABEL_CLASS}>Duration</p>
255
245
  <div className="flex flex-wrap gap-[var(--spacing-system-xs)]">
@@ -62,6 +62,7 @@ export {
62
62
  type BookingFormProps,
63
63
  type BookingFieldRow,
64
64
  type BookingFieldSlot,
65
+ type BookingFormConsent,
65
66
  } from './booking-form';
66
67
  import { Confirmation } from './confirmation';
67
68
  import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
@@ -706,8 +707,6 @@ export function HubSpotMeetingScheduler({
706
707
  // summary line on the form — so the picker stays until there is
707
708
  // nothing left to re-read in it.
708
709
  showTimezone={step !== 'confirmed'}
709
- // The mock's calendar step states the length, it does not offer it.
710
- fixedDuration={detailsFirst}
711
710
  className={CONTEXT_PANEL_CLASS}
712
711
  />
713
712
  )}