@flamingo-stack/openframe-frontend-core 0.0.595 → 0.0.596-2034.5670.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/{chunk-U4QRK4MX.js → chunk-27U3NNFR.js} +4 -4
- package/dist/{chunk-OGE4GSLZ.cjs → chunk-2EPDTX4M.cjs} +84 -84
- package/dist/{chunk-OGE4GSLZ.cjs.map → chunk-2EPDTX4M.cjs.map} +1 -1
- package/dist/{chunk-BKMSI2AW.js → chunk-2KQO7OMB.js} +2 -2
- package/dist/{chunk-A57HKYLY.js → chunk-2NZKBKO6.js} +6 -6
- package/dist/{chunk-CPWX46TL.cjs → chunk-53HSPYKM.cjs} +6 -6
- package/dist/{chunk-CPWX46TL.cjs.map → chunk-53HSPYKM.cjs.map} +1 -1
- package/dist/{chunk-FHEN27GM.cjs → chunk-5K6GZANU.cjs} +11 -11
- package/dist/{chunk-FHEN27GM.cjs.map → chunk-5K6GZANU.cjs.map} +1 -1
- package/dist/{chunk-23UJ4EBJ.cjs → chunk-7CKSLZT6.cjs} +12 -12
- package/dist/{chunk-23UJ4EBJ.cjs.map → chunk-7CKSLZT6.cjs.map} +1 -1
- package/dist/{chunk-T2JAJ7MR.js → chunk-7YUFH47E.js} +26 -13
- package/dist/chunk-7YUFH47E.js.map +1 -0
- package/dist/{chunk-OWWICTQR.cjs → chunk-AN4CB7U3.cjs} +24 -24
- package/dist/{chunk-OWWICTQR.cjs.map → chunk-AN4CB7U3.cjs.map} +1 -1
- package/dist/{chunk-3WZLPCJ7.js → chunk-BDLFPDAW.js} +4 -4
- package/dist/{chunk-QLJMXCB5.js → chunk-BHIJ5OGK.js} +5 -5
- package/dist/{chunk-AQ65NLGJ.js → chunk-E7MTTLKC.js} +3 -3
- package/dist/{chunk-MUNVPRT2.js → chunk-EVMHJB6X.js} +2 -2
- package/dist/{chunk-MOHSA65M.js → chunk-EZM5DNII.js} +2 -2
- package/dist/{chunk-7N2NJPUK.js → chunk-FPG4S3X4.js} +4 -4
- package/dist/{chunk-HGWC7ABT.cjs → chunk-FUHYCQLO.cjs} +63 -63
- package/dist/{chunk-HGWC7ABT.cjs.map → chunk-FUHYCQLO.cjs.map} +1 -1
- package/dist/{chunk-MHORK6KM.cjs → chunk-IIWB7VEC.cjs} +3 -3
- package/dist/{chunk-MHORK6KM.cjs.map → chunk-IIWB7VEC.cjs.map} +1 -1
- package/dist/{chunk-ZY3Z3F4X.cjs → chunk-IJY6E2YO.cjs} +49 -49
- package/dist/{chunk-ZY3Z3F4X.cjs.map → chunk-IJY6E2YO.cjs.map} +1 -1
- package/dist/{chunk-5EM6NGBK.cjs → chunk-KOQCA5X5.cjs} +17 -27
- package/dist/chunk-KOQCA5X5.cjs.map +1 -0
- package/dist/{chunk-YYHYCQD6.cjs → chunk-L7GNBUXF.cjs} +168 -168
- package/dist/{chunk-YYHYCQD6.cjs.map → chunk-L7GNBUXF.cjs.map} +1 -1
- package/dist/{chunk-A5AKGC4S.js → chunk-LPMDCQLL.js} +17 -27
- package/dist/chunk-LPMDCQLL.js.map +1 -0
- package/dist/{chunk-4ZY5MP46.cjs → chunk-LPWTGNNP.cjs} +15 -15
- package/dist/{chunk-4ZY5MP46.cjs.map → chunk-LPWTGNNP.cjs.map} +1 -1
- package/dist/{chunk-FKXDDJEQ.cjs → chunk-M4YQUHOR.cjs} +5 -5
- package/dist/{chunk-FKXDDJEQ.cjs.map → chunk-M4YQUHOR.cjs.map} +1 -1
- package/dist/{chunk-PYEJL2UG.js → chunk-MOKMFLKB.js} +3 -3
- package/dist/{chunk-ZRXJX3JK.js → chunk-NRGBR3SN.js} +6 -6
- package/dist/{chunk-DYXVQ24Q.js → chunk-ONZCED5F.js} +3 -3
- package/dist/{chunk-UPKN5CYH.js → chunk-P367JZLK.js} +8 -8
- package/dist/{chunk-63PXZXCT.js → chunk-PPY2IRJP.js} +4 -4
- package/dist/{chunk-CZM2H5F5.cjs → chunk-QBXD4KOR.cjs} +5 -5
- package/dist/{chunk-CZM2H5F5.cjs.map → chunk-QBXD4KOR.cjs.map} +1 -1
- package/dist/{chunk-JGDDPNSC.cjs → chunk-QW663C35.cjs} +298 -285
- package/dist/chunk-QW663C35.cjs.map +1 -0
- package/dist/{chunk-6PEFK4UH.cjs → chunk-QX6GS6Y3.cjs} +9 -9
- package/dist/{chunk-6PEFK4UH.cjs.map → chunk-QX6GS6Y3.cjs.map} +1 -1
- package/dist/{chunk-SEVD4AST.js → chunk-RY2MDY6G.js} +7 -7
- package/dist/{chunk-4R6AZFNU.cjs → chunk-SAEJGTCI.cjs} +30 -30
- package/dist/{chunk-4R6AZFNU.cjs.map → chunk-SAEJGTCI.cjs.map} +1 -1
- package/dist/{chunk-TDEQ26A2.js → chunk-UAAXUFK6.js} +1 -1
- package/dist/chunk-UAAXUFK6.js.map +1 -0
- package/dist/{chunk-EO5GYWIX.cjs → chunk-V7SYWH7D.cjs} +40 -40
- package/dist/{chunk-EO5GYWIX.cjs.map → chunk-V7SYWH7D.cjs.map} +1 -1
- package/dist/{chunk-MX7DNV7X.cjs → chunk-VZMA3IUV.cjs} +33 -33
- package/dist/{chunk-MX7DNV7X.cjs.map → chunk-VZMA3IUV.cjs.map} +1 -1
- package/dist/{chunk-NNMAMTA5.js → chunk-WC3M4XJ2.js} +3 -3
- package/dist/{chunk-SK4IVCSI.cjs → chunk-WCBRZCU4.cjs} +1 -1
- package/dist/chunk-WCBRZCU4.cjs.map +1 -0
- package/dist/{chunk-2WLW5HDU.js → chunk-WE4OW4XP.js} +8 -8
- package/dist/{chunk-MDUAU74C.cjs → chunk-WKZ3JYMP.cjs} +28 -28
- package/dist/{chunk-MDUAU74C.cjs.map → chunk-WKZ3JYMP.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +16 -16
- package/dist/components/case-studies/index.js +6 -6
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +7 -7
- package/dist/components/contact/index.js +6 -6
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +7 -7
- package/dist/components/embeds/index.js +6 -6
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/index.cjs +6 -6
- package/dist/components/features/index.js +5 -5
- package/dist/components/help-center-pages/index.cjs +26 -26
- package/dist/components/help-center-pages/index.js +17 -17
- package/dist/components/icons/index.cjs +2 -2
- package/dist/components/icons/index.js +1 -1
- package/dist/components/icons/mlg-logo.d.ts.map +1 -1
- package/dist/components/index.cjs +56 -56
- package/dist/components/index.js +17 -17
- package/dist/components/meeting-scheduler/booking-form.d.ts +70 -6
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/context-panel.d.ts +9 -1
- package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +395 -188
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts +55 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +381 -174
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/components/meeting-scheduler/slot-picker.d.ts +8 -1
- package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +9 -9
- package/dist/components/navigation/index.js +8 -8
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +10 -10
- package/dist/components/tickets/index.js +9 -9
- package/dist/components/ui/benefit-card.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -6
- package/dist/components/ui/index.js +5 -5
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +10 -10
- package/dist/index.js +9 -9
- package/dist/schemas/meeting-booking-schema.cjs +19 -4
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +41 -16
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +19 -5
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/index.cjs +16 -26
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +16 -26
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/icons/mlg-logo.tsx +1 -7
- package/src/components/meeting-scheduler/booking-form.tsx +425 -154
- package/src/components/meeting-scheduler/context-panel.tsx +13 -3
- package/src/components/meeting-scheduler/index.tsx +317 -69
- package/src/components/meeting-scheduler/slot-picker.tsx +17 -1
- package/src/components/ui/benefit-card.tsx +30 -9
- package/src/schemas/meeting-booking-schema.ts +58 -4
- package/src/types/tmcg.ts +0 -2
- package/src/utils/format.ts +1 -1
- package/dist/chunk-5EM6NGBK.cjs.map +0 -1
- package/dist/chunk-A5AKGC4S.js.map +0 -1
- package/dist/chunk-JGDDPNSC.cjs.map +0 -1
- package/dist/chunk-SK4IVCSI.cjs.map +0 -1
- package/dist/chunk-T2JAJ7MR.js.map +0 -1
- package/dist/chunk-TDEQ26A2.js.map +0 -1
- /package/dist/{chunk-U4QRK4MX.js.map → chunk-27U3NNFR.js.map} +0 -0
- /package/dist/{chunk-BKMSI2AW.js.map → chunk-2KQO7OMB.js.map} +0 -0
- /package/dist/{chunk-A57HKYLY.js.map → chunk-2NZKBKO6.js.map} +0 -0
- /package/dist/{chunk-3WZLPCJ7.js.map → chunk-BDLFPDAW.js.map} +0 -0
- /package/dist/{chunk-QLJMXCB5.js.map → chunk-BHIJ5OGK.js.map} +0 -0
- /package/dist/{chunk-AQ65NLGJ.js.map → chunk-E7MTTLKC.js.map} +0 -0
- /package/dist/{chunk-MUNVPRT2.js.map → chunk-EVMHJB6X.js.map} +0 -0
- /package/dist/{chunk-MOHSA65M.js.map → chunk-EZM5DNII.js.map} +0 -0
- /package/dist/{chunk-7N2NJPUK.js.map → chunk-FPG4S3X4.js.map} +0 -0
- /package/dist/{chunk-PYEJL2UG.js.map → chunk-MOKMFLKB.js.map} +0 -0
- /package/dist/{chunk-ZRXJX3JK.js.map → chunk-NRGBR3SN.js.map} +0 -0
- /package/dist/{chunk-DYXVQ24Q.js.map → chunk-ONZCED5F.js.map} +0 -0
- /package/dist/{chunk-UPKN5CYH.js.map → chunk-P367JZLK.js.map} +0 -0
- /package/dist/{chunk-63PXZXCT.js.map → chunk-PPY2IRJP.js.map} +0 -0
- /package/dist/{chunk-SEVD4AST.js.map → chunk-RY2MDY6G.js.map} +0 -0
- /package/dist/{chunk-NNMAMTA5.js.map → chunk-WC3M4XJ2.js.map} +0 -0
- /package/dist/{chunk-2WLW5HDU.js.map → chunk-WE4OW4XP.js.map} +0 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
-
import { useMemo } from 'react';
|
|
5
|
-
import type { 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,
|
|
9
9
|
isSupportedFormField,
|
|
10
10
|
type MeetingAvailability,
|
|
11
|
-
type
|
|
11
|
+
type MeetingFormField,
|
|
12
|
+
type BookingFormValues,
|
|
12
13
|
} from '../../schemas/meeting-booking-schema';
|
|
14
|
+
import { cn } from '../../utils/cn';
|
|
13
15
|
import {
|
|
14
16
|
Button,
|
|
15
17
|
FieldWrapper,
|
|
@@ -22,11 +24,56 @@ import {
|
|
|
22
24
|
SelectContent,
|
|
23
25
|
SelectItem,
|
|
24
26
|
Checkbox,
|
|
27
|
+
CheckboxBlock,
|
|
25
28
|
RadioGroup,
|
|
26
29
|
RadioGroupItem,
|
|
30
|
+
Skeleton,
|
|
27
31
|
} from '../ui';
|
|
28
32
|
import { HoneypotField } from '../ui/honeypot-field';
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* One field in a host-supplied row. `name` is a built-in (`email`, `firstName`,
|
|
36
|
+
* `lastName`) or a HubSpot-declared question's `name`.
|
|
37
|
+
*/
|
|
38
|
+
export interface BookingFieldSlot {
|
|
39
|
+
name: string;
|
|
40
|
+
/** Columns out of four at `md` and up. Defaults to an even split of the row. */
|
|
41
|
+
span?: 1 | 2 | 3 | 4;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BookingFieldRow = BookingFieldSlot[];
|
|
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
|
+
|
|
65
|
+
/** Static so Tailwind's scanner sees every class — a template built from a
|
|
66
|
+
* runtime span would compile to nothing. */
|
|
67
|
+
const SPAN_CLASS: Record<1 | 2 | 3 | 4, string> = {
|
|
68
|
+
1: 'md:col-span-1',
|
|
69
|
+
2: 'md:col-span-2',
|
|
70
|
+
3: 'md:col-span-3',
|
|
71
|
+
4: 'md:col-span-4',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const evenSpan = (count: number): 1 | 2 | 3 | 4 =>
|
|
75
|
+
Math.min(4, Math.max(1, Math.floor(4 / Math.max(1, count)))) as 1 | 2 | 3 | 4;
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* BookingForm — attendee details + the link's declared custom questions +
|
|
32
79
|
* verbatim legal-consent copy. ContactForm's scaffolding (react-hook-form +
|
|
@@ -45,10 +92,36 @@ import { HoneypotField } from '../ui/honeypot-field';
|
|
|
45
92
|
export interface BookingFormProps {
|
|
46
93
|
availability: MeetingAvailability;
|
|
47
94
|
meetingId: string;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
95
|
+
/** Absent in `deferSlot` mode — the slot is chosen AFTER these answers. */
|
|
96
|
+
startTimeMs?: number;
|
|
97
|
+
durationMs?: number;
|
|
98
|
+
/** IANA zone the confirmation/invite should render in (parent-resolved).
|
|
99
|
+
* Null until hydration, which is why `deferSlot` relaxes it. */
|
|
100
|
+
timezone: string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Collect-only mode (`flow="details-first"`): validate against the deferred
|
|
103
|
+
* schema, and hand the values up instead of POSTing. The parent re-attaches
|
|
104
|
+
* the authoritative slot/duration/timezone when it submits.
|
|
105
|
+
*/
|
|
106
|
+
deferSlot?: boolean;
|
|
107
|
+
/** Repopulates the form on a remount — the back edge, or an error return. */
|
|
108
|
+
initialValues?: Record<string, unknown>;
|
|
109
|
+
/** Defaults to "Confirm Booking"; details-first says "Continue". */
|
|
110
|
+
submitLabel?: string;
|
|
111
|
+
/** Small print beside the submit (details-first sets expectations). */
|
|
112
|
+
footerNote?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Re-arrange the fields into rows instead of the built-in order (email, the
|
|
115
|
+
* name pair, then each declared question full width). Reuse, not a fork: the
|
|
116
|
+
* SAME controls, validation, consent block and honeypot — only their grouping
|
|
117
|
+
* changes, so a host can match a mock without owning the machine.
|
|
118
|
+
*
|
|
119
|
+
* A slot naming nothing is skipped; a declared question no row claims is
|
|
120
|
+
* appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
|
|
121
|
+
*/
|
|
122
|
+
fieldRows?: BookingFieldRow[];
|
|
123
|
+
/** Host-supplied consent row, rendered after the fields — see `BookingFormConsent`. */
|
|
124
|
+
consent?: BookingFormConsent;
|
|
52
125
|
isSubmitting: boolean;
|
|
53
126
|
onSubmit: (payload: Record<string, unknown>) => Promise<void>;
|
|
54
127
|
/** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
|
|
@@ -62,6 +135,12 @@ export function BookingForm({
|
|
|
62
135
|
startTimeMs,
|
|
63
136
|
durationMs,
|
|
64
137
|
timezone,
|
|
138
|
+
deferSlot = false,
|
|
139
|
+
initialValues,
|
|
140
|
+
submitLabel,
|
|
141
|
+
footerNote,
|
|
142
|
+
fieldRows,
|
|
143
|
+
consent,
|
|
65
144
|
isSubmitting,
|
|
66
145
|
onSubmit,
|
|
67
146
|
honeypotInputProps,
|
|
@@ -69,36 +148,92 @@ export function BookingForm({
|
|
|
69
148
|
}: BookingFormProps) {
|
|
70
149
|
const { formFields, legalConsent } = availability;
|
|
71
150
|
const supportedFields = useMemo(() => formFields.filter(isSupportedFormField), [formFields]);
|
|
72
|
-
|
|
151
|
+
// The DEFERRED schema in both flows: it is the wider of the two, and a strict
|
|
152
|
+
// resolver is not assignable to `Resolver<BookingFormValues>`. The strict
|
|
153
|
+
// schema is the server's contract — see `makeBookingSchema`'s docblock.
|
|
154
|
+
const schema = useMemo(
|
|
155
|
+
() => makeDeferredBookingSchema(supportedFields, legalConsent),
|
|
156
|
+
[supportedFields, legalConsent],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const consentDefaults = useMemo(
|
|
160
|
+
() =>
|
|
161
|
+
(legalConsent?.communicationConsentCheckboxes ?? []).map(c => ({
|
|
162
|
+
communicationTypeId: c.communicationTypeId,
|
|
163
|
+
consented: false,
|
|
164
|
+
})),
|
|
165
|
+
[legalConsent],
|
|
166
|
+
);
|
|
73
167
|
|
|
74
168
|
const {
|
|
75
169
|
register,
|
|
76
170
|
control,
|
|
77
171
|
handleSubmit,
|
|
172
|
+
setValue,
|
|
78
173
|
formState: { errors },
|
|
79
|
-
} = useForm<
|
|
174
|
+
} = useForm<BookingFormValues>({
|
|
80
175
|
resolver: zodResolver(schema),
|
|
81
176
|
defaultValues: {
|
|
82
177
|
meetingId,
|
|
83
|
-
|
|
84
|
-
durationMs,
|
|
178
|
+
// Genuinely OMITTED, not present-as-undefined, when the slot is deferred.
|
|
179
|
+
...(deferSlot ? {} : { startTimeMs, durationMs }),
|
|
85
180
|
timezone,
|
|
86
181
|
locale: typeof navigator !== 'undefined' ? navigator.language : undefined,
|
|
87
182
|
firstName: '',
|
|
88
183
|
lastName: '',
|
|
89
184
|
email: '',
|
|
90
185
|
formFields: {},
|
|
91
|
-
legalConsentResponses:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
})
|
|
95
|
-
},
|
|
186
|
+
legalConsentResponses: consentDefaults,
|
|
187
|
+
// A remount (back edge, or an error return) would otherwise lose every
|
|
188
|
+
// answer — `defaultValues` is snapshotted once and never re-read.
|
|
189
|
+
...(initialValues ?? {}),
|
|
190
|
+
} as BookingFormValues,
|
|
96
191
|
});
|
|
97
192
|
|
|
98
|
-
const
|
|
99
|
-
|
|
193
|
+
const priorConsents = initialValues?.legalConsentResponses as typeof consentDefaults | undefined;
|
|
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
|
+
|
|
201
|
+
// The seeded availability is refetched immediately on mount, so the link's
|
|
202
|
+
// consent set can change WHILE this form is open. Reconcile rather than
|
|
203
|
+
// remount: carry each `consented` across by id and default new ids to false.
|
|
204
|
+
// A wholesale reset would drop what the visitor has typed; leaving it alone
|
|
205
|
+
// would make a newly-declared required consent impossible to satisfy.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
setValue(
|
|
208
|
+
'legalConsentResponses',
|
|
209
|
+
consentDefaults.map(next => ({
|
|
210
|
+
...next,
|
|
211
|
+
consented: priorConsents?.find(r => r.communicationTypeId === next.communicationTypeId)?.consented ?? false,
|
|
212
|
+
})),
|
|
213
|
+
{ shouldDirty: false },
|
|
214
|
+
);
|
|
215
|
+
}, [consentDefaults, priorConsents, setValue]);
|
|
216
|
+
|
|
217
|
+
const submitValid = handleSubmit(async data => {
|
|
218
|
+
if (consentMissing) return; // the error is already on screen — see `submit`
|
|
219
|
+
if (deferSlot) {
|
|
220
|
+
// Collect-only. Signals are captured HERE, while this form and its
|
|
221
|
+
// honeypot are still mounted — `getSignals()` reads a detached ref once
|
|
222
|
+
// they unmount, which would silently disable the decoy.
|
|
223
|
+
await onSubmit({ ...data, meetingId, hostConsent: consented });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await onSubmit({ ...data, meetingId, hostConsent: consented, startTimeMs, durationMs, timezone, ...getSignals() });
|
|
100
227
|
});
|
|
101
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
|
+
|
|
102
237
|
const fieldError = (name: string): string | undefined => {
|
|
103
238
|
const err = (errors.formFields as Record<string, { message?: string }> | undefined)?.[name];
|
|
104
239
|
return err?.message;
|
|
@@ -116,6 +251,161 @@ export function BookingForm({
|
|
|
116
251
|
// smallest ODS step that houses it.
|
|
117
252
|
const FORM_STACK = 'flex flex-col gap-[var(--spacing-system-l)]';
|
|
118
253
|
|
|
254
|
+
// Every field as a standalone node, so the default order below and any
|
|
255
|
+
// host-supplied `fieldRows` compose the SAME controls. Extracting them is what
|
|
256
|
+
// makes an injected layout a re-arrangement rather than a fork of this form.
|
|
257
|
+
const builtInFields: Record<string, ReactNode> = {
|
|
258
|
+
email: (
|
|
259
|
+
<FieldWrapper label="Email" htmlFor="ms-email" error={errors.email?.message}>
|
|
260
|
+
<Input
|
|
261
|
+
id="ms-email"
|
|
262
|
+
type="email"
|
|
263
|
+
required
|
|
264
|
+
autoComplete="email"
|
|
265
|
+
placeholder="jane@company.com"
|
|
266
|
+
aria-invalid={!!errors.email}
|
|
267
|
+
className={inputClass}
|
|
268
|
+
{...register('email')}
|
|
269
|
+
/>
|
|
270
|
+
</FieldWrapper>
|
|
271
|
+
),
|
|
272
|
+
firstName: (
|
|
273
|
+
<FieldWrapper label="First Name" htmlFor="ms-first-name" error={errors.firstName?.message}>
|
|
274
|
+
<Input
|
|
275
|
+
id="ms-first-name"
|
|
276
|
+
required
|
|
277
|
+
autoComplete="given-name"
|
|
278
|
+
placeholder="Jane"
|
|
279
|
+
aria-invalid={!!errors.firstName}
|
|
280
|
+
className={inputClass}
|
|
281
|
+
{...register('firstName')}
|
|
282
|
+
/>
|
|
283
|
+
</FieldWrapper>
|
|
284
|
+
),
|
|
285
|
+
lastName: (
|
|
286
|
+
<FieldWrapper label="Last Name" htmlFor="ms-last-name" error={errors.lastName?.message}>
|
|
287
|
+
<Input
|
|
288
|
+
id="ms-last-name"
|
|
289
|
+
required
|
|
290
|
+
autoComplete="family-name"
|
|
291
|
+
placeholder="Doe"
|
|
292
|
+
aria-invalid={!!errors.lastName}
|
|
293
|
+
className={inputClass}
|
|
294
|
+
{...register('lastName')}
|
|
295
|
+
/>
|
|
296
|
+
</FieldWrapper>
|
|
297
|
+
),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const renderDeclaredField = (field: MeetingFormField): ReactNode => (
|
|
301
|
+
<FieldWrapper key={field.name} label={field.label} htmlFor={`ms-q-${field.name}`} error={fieldError(field.name)}>
|
|
302
|
+
{field.type === 'textarea' && (
|
|
303
|
+
<Textarea
|
|
304
|
+
id={`ms-q-${field.name}`}
|
|
305
|
+
className="border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary"
|
|
306
|
+
{...register(`formFields.${field.name}` as never)}
|
|
307
|
+
/>
|
|
308
|
+
)}
|
|
309
|
+
{field.type === 'text' && (
|
|
310
|
+
<Input
|
|
311
|
+
id={`ms-q-${field.name}`}
|
|
312
|
+
className="h-11 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary md:h-12"
|
|
313
|
+
{...register(`formFields.${field.name}` as never)}
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
{(field.type === 'select' || field.type === 'radio') && (
|
|
317
|
+
<Controller
|
|
318
|
+
control={control}
|
|
319
|
+
name={`formFields.${field.name}` as never}
|
|
320
|
+
render={({ field: rhf }) =>
|
|
321
|
+
field.type === 'select' ? (
|
|
322
|
+
<Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
|
|
323
|
+
<SelectTrigger id={`ms-q-${field.name}`}>
|
|
324
|
+
<SelectValue placeholder="Select…" />
|
|
325
|
+
</SelectTrigger>
|
|
326
|
+
<SelectContent>
|
|
327
|
+
{(field.options ?? []).map(opt => (
|
|
328
|
+
<SelectItem key={opt} value={opt}>
|
|
329
|
+
{opt}
|
|
330
|
+
</SelectItem>
|
|
331
|
+
))}
|
|
332
|
+
</SelectContent>
|
|
333
|
+
</Select>
|
|
334
|
+
) : (
|
|
335
|
+
<RadioGroup value={rhf.value ?? ''} onValueChange={rhf.onChange}>
|
|
336
|
+
{(field.options ?? []).map(opt => (
|
|
337
|
+
<div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
338
|
+
<RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />
|
|
339
|
+
<Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>
|
|
340
|
+
</div>
|
|
341
|
+
))}
|
|
342
|
+
</RadioGroup>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
{field.type === 'checkbox' && (
|
|
348
|
+
<Controller
|
|
349
|
+
control={control}
|
|
350
|
+
name={`formFields.${field.name}` as never}
|
|
351
|
+
render={({ field: rhf }) => (
|
|
352
|
+
// The question's Label above already carries the text and
|
|
353
|
+
// binds to this id — a second inline label would double the
|
|
354
|
+
// visible text AND the accessible name.
|
|
355
|
+
<div className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
356
|
+
<Checkbox
|
|
357
|
+
id={`ms-q-${field.name}`}
|
|
358
|
+
checked={Boolean(rhf.value)}
|
|
359
|
+
onCheckedChange={v => rhf.onChange(v === true)}
|
|
360
|
+
/>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
</FieldWrapper>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const declaredByName = new Map(supportedFields.map(f => [f.name, f]));
|
|
369
|
+
|
|
370
|
+
const slotResolves = (name: string): boolean => Boolean(builtInFields[name] || declaredByName.has(name));
|
|
371
|
+
|
|
372
|
+
const slotNode = (name: string): ReactNode => {
|
|
373
|
+
const builtIn = builtInFields[name];
|
|
374
|
+
if (builtIn) return builtIn;
|
|
375
|
+
const declared = declaredByName.get(name);
|
|
376
|
+
// A name matching nothing is SKIPPED, not an error: a row may reference a
|
|
377
|
+
// question the link has not declared yet (declaring it in HubSpot is what
|
|
378
|
+
// turns it on), and throwing would take the whole form down over config.
|
|
379
|
+
return declared ? renderDeclaredField(declared) : null;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** Declared questions no row claims — appended full width, so a question added
|
|
383
|
+
* in HubSpot can never go invisible by omission from a layout written before it. */
|
|
384
|
+
const unplacedFields = fieldRows
|
|
385
|
+
? supportedFields.filter(f => !fieldRows.some(row => row.some(slot => slot.name === f.name)))
|
|
386
|
+
: [];
|
|
387
|
+
|
|
388
|
+
/** Rows whose every slot names a question the link has not declared are
|
|
389
|
+
* DROPPED, not rendered empty: an empty grid still eats one form gap, so a
|
|
390
|
+
* layout written ahead of the HubSpot config would print blank bands. */
|
|
391
|
+
const placedRows = (fieldRows ?? [])
|
|
392
|
+
.map(row => row.filter(slot => slotResolves(slot.name)))
|
|
393
|
+
.filter(row => row.length > 0);
|
|
394
|
+
|
|
395
|
+
const submitButton = (
|
|
396
|
+
<Button
|
|
397
|
+
type="submit"
|
|
398
|
+
loading={isSubmitting}
|
|
399
|
+
disabled={isSubmitting}
|
|
400
|
+
// The details-first footer draws a 240px action beside its note
|
|
401
|
+
// (`4904:117335`); the bare slot-first row keeps the button at its natural
|
|
402
|
+
// width, as it always has.
|
|
403
|
+
className={footerNote ? 'md:w-60' : undefined}
|
|
404
|
+
>
|
|
405
|
+
{submitLabel ?? 'Confirm Booking'}
|
|
406
|
+
</Button>
|
|
407
|
+
);
|
|
408
|
+
|
|
119
409
|
return (
|
|
120
410
|
<form onSubmit={submit} className={FORM_STACK} noValidate>
|
|
121
411
|
<HoneypotField {...honeypotInputProps} />
|
|
@@ -132,116 +422,61 @@ export function BookingForm({
|
|
|
132
422
|
submit button off the bottom the moment validation fails. Required-ness
|
|
133
423
|
is carried by `required` on the control (read out by assistive tech),
|
|
134
424
|
not by an asterisk the design does not draw. */}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
aria-invalid={!!errors.firstName}
|
|
156
|
-
className={inputClass}
|
|
157
|
-
{...register('firstName')}
|
|
158
|
-
/>
|
|
159
|
-
</FieldWrapper>
|
|
160
|
-
<FieldWrapper label="Last Name" htmlFor="ms-last-name" error={errors.lastName?.message}>
|
|
161
|
-
<Input
|
|
162
|
-
id="ms-last-name"
|
|
163
|
-
required
|
|
164
|
-
autoComplete="family-name"
|
|
165
|
-
placeholder="Doe"
|
|
166
|
-
aria-invalid={!!errors.lastName}
|
|
167
|
-
className={inputClass}
|
|
168
|
-
{...register('lastName')}
|
|
169
|
-
/>
|
|
170
|
-
</FieldWrapper>
|
|
171
|
-
</div>
|
|
172
|
-
|
|
173
|
-
{supportedFields.map(field => (
|
|
174
|
-
<FieldWrapper
|
|
175
|
-
key={field.name}
|
|
176
|
-
label={field.label}
|
|
177
|
-
htmlFor={`ms-q-${field.name}`}
|
|
178
|
-
error={fieldError(field.name)}
|
|
179
|
-
>
|
|
180
|
-
{field.type === 'textarea' && (
|
|
181
|
-
<Textarea
|
|
182
|
-
id={`ms-q-${field.name}`}
|
|
183
|
-
className="border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary"
|
|
184
|
-
{...register(`formFields.${field.name}` as never)}
|
|
185
|
-
/>
|
|
186
|
-
)}
|
|
187
|
-
{field.type === 'text' && (
|
|
188
|
-
<Input
|
|
189
|
-
id={`ms-q-${field.name}`}
|
|
190
|
-
className="h-11 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary md:h-12"
|
|
191
|
-
{...register(`formFields.${field.name}` as never)}
|
|
192
|
-
/>
|
|
193
|
-
)}
|
|
194
|
-
{(field.type === 'select' || field.type === 'radio') && (
|
|
195
|
-
<Controller
|
|
196
|
-
control={control}
|
|
197
|
-
name={`formFields.${field.name}` as never}
|
|
198
|
-
render={({ field: rhf }) =>
|
|
199
|
-
field.type === 'select' ? (
|
|
200
|
-
<Select value={rhf.value ?? ''} onValueChange={rhf.onChange}>
|
|
201
|
-
<SelectTrigger id={`ms-q-${field.name}`}>
|
|
202
|
-
<SelectValue placeholder="Select…" />
|
|
203
|
-
</SelectTrigger>
|
|
204
|
-
<SelectContent>
|
|
205
|
-
{(field.options ?? []).map(opt => (
|
|
206
|
-
<SelectItem key={opt} value={opt}>
|
|
207
|
-
{opt}
|
|
208
|
-
</SelectItem>
|
|
209
|
-
))}
|
|
210
|
-
</SelectContent>
|
|
211
|
-
</Select>
|
|
212
|
-
) : (
|
|
213
|
-
<RadioGroup value={rhf.value ?? ''} onValueChange={rhf.onChange}>
|
|
214
|
-
{(field.options ?? []).map(opt => (
|
|
215
|
-
<div key={opt} className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
216
|
-
<RadioGroupItem id={`ms-q-${field.name}-${opt}`} value={opt} />
|
|
217
|
-
<Label htmlFor={`ms-q-${field.name}-${opt}`}>{opt}</Label>
|
|
218
|
-
</div>
|
|
219
|
-
))}
|
|
220
|
-
</RadioGroup>
|
|
221
|
-
)
|
|
222
|
-
}
|
|
223
|
-
/>
|
|
224
|
-
)}
|
|
225
|
-
{field.type === 'checkbox' && (
|
|
226
|
-
<Controller
|
|
227
|
-
control={control}
|
|
228
|
-
name={`formFields.${field.name}` as never}
|
|
229
|
-
render={({ field: rhf }) => (
|
|
230
|
-
// The question's Label above already carries the text and
|
|
231
|
-
// binds to this id — a second inline label would double the
|
|
232
|
-
// visible text AND the accessible name.
|
|
233
|
-
<div className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
234
|
-
<Checkbox
|
|
235
|
-
id={`ms-q-${field.name}`}
|
|
236
|
-
checked={Boolean(rhf.value)}
|
|
237
|
-
onCheckedChange={v => rhf.onChange(v === true)}
|
|
238
|
-
/>
|
|
425
|
+
{fieldRows ? (
|
|
426
|
+
<>
|
|
427
|
+
{placedRows.map((row, rowIndex) => (
|
|
428
|
+
<div
|
|
429
|
+
key={row.map(s => s.name).join('|') || rowIndex}
|
|
430
|
+
className="grid grid-cols-2 gap-[var(--spacing-system-m)] md:grid-cols-4"
|
|
431
|
+
>
|
|
432
|
+
{row.map(slot => (
|
|
433
|
+
<div
|
|
434
|
+
key={slot.name}
|
|
435
|
+
className={cn(
|
|
436
|
+
// A two-field row stays side by side on a phone — the rule
|
|
437
|
+
// the built-in name pair has always followed: two short
|
|
438
|
+
// fields cost one line instead of two on the layout that can
|
|
439
|
+
// least afford them.
|
|
440
|
+
row.length === 2 ? 'col-span-1' : 'col-span-2',
|
|
441
|
+
SPAN_CLASS[slot.span ?? evenSpan(row.length)],
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
{slotNode(slot.name)}
|
|
239
445
|
</div>
|
|
240
|
-
)}
|
|
241
|
-
|
|
242
|
-
)}
|
|
243
|
-
|
|
244
|
-
|
|
446
|
+
))}
|
|
447
|
+
</div>
|
|
448
|
+
))}
|
|
449
|
+
{unplacedFields.map(field => (
|
|
450
|
+
<Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
|
|
451
|
+
))}
|
|
452
|
+
</>
|
|
453
|
+
) : (
|
|
454
|
+
<>
|
|
455
|
+
{builtInFields.email}
|
|
456
|
+
<div className="grid grid-cols-2 gap-[var(--spacing-system-m)]">
|
|
457
|
+
{builtInFields.firstName}
|
|
458
|
+
{builtInFields.lastName}
|
|
459
|
+
</div>
|
|
460
|
+
{supportedFields.map(field => (
|
|
461
|
+
<Fragment key={field.name}>{renderDeclaredField(field)}</Fragment>
|
|
462
|
+
))}
|
|
463
|
+
</>
|
|
464
|
+
)}
|
|
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
|
+
)}
|
|
245
480
|
|
|
246
481
|
{legalConsent && (
|
|
247
482
|
// Same out-of-flow message as the fields above, so a missed consent
|
|
@@ -249,40 +484,44 @@ export function BookingForm({
|
|
|
249
484
|
// wrapper is `display:contents` and the panel below is the flex item,
|
|
250
485
|
// exactly as it was.
|
|
251
486
|
<FieldWrapper error={errors.legalConsentResponses?.message}>
|
|
252
|
-
<div className="flex flex-col gap-[var(--spacing-system-xs)]
|
|
253
|
-
{/* GDPR surface — HubSpot's consent copy rendered VERBATIM, never edited. */}
|
|
254
|
-
<p className="text-ods-text-secondary text-h6">{legalConsent.processingConsentText}</p>
|
|
255
|
-
{legalConsent.communicationConsentText && (
|
|
256
|
-
<p className="text-ods-text-secondary text-h6">{legalConsent.communicationConsentText}</p>
|
|
257
|
-
)}
|
|
487
|
+
<div className="flex flex-col gap-[var(--spacing-system-xs)]">
|
|
258
488
|
<Controller
|
|
259
489
|
control={control}
|
|
260
490
|
name="legalConsentResponses"
|
|
261
491
|
render={({ field: rhf }) => (
|
|
262
492
|
<>
|
|
263
|
-
{legalConsent.communicationConsentCheckboxes.map(box => {
|
|
493
|
+
{legalConsent.communicationConsentCheckboxes.map((box, index) => {
|
|
264
494
|
const responses = (rhf.value ?? []) as Array<{ communicationTypeId: string; consented: boolean }>;
|
|
265
495
|
const current = responses.find(r => r.communicationTypeId === box.communicationTypeId);
|
|
266
496
|
return (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
497
|
+
// The design system's consent row (`checkbox-block`, the
|
|
498
|
+
// same block the waitlist form uses): box, label, caption
|
|
499
|
+
// in one bordered row. GDPR surface — HubSpot's copy is
|
|
500
|
+
// rendered VERBATIM, never edited. The processing statement
|
|
501
|
+
// rides as the caption of the FIRST row, once: it describes
|
|
502
|
+
// the consent as a whole, not each channel.
|
|
503
|
+
<CheckboxBlock
|
|
504
|
+
key={box.communicationTypeId}
|
|
505
|
+
id={`ms-consent-${box.communicationTypeId}`}
|
|
506
|
+
checked={current?.consented ?? false}
|
|
507
|
+
onCheckedChange={v =>
|
|
508
|
+
rhf.onChange(
|
|
509
|
+
responses.map(r =>
|
|
510
|
+
r.communicationTypeId === box.communicationTypeId ? { ...r, consented: v } : r,
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
label={box.label}
|
|
515
|
+
description={index === 0 ? legalConsent.processingConsentText : undefined}
|
|
516
|
+
/>
|
|
281
517
|
);
|
|
282
518
|
})}
|
|
283
519
|
</>
|
|
284
520
|
)}
|
|
285
521
|
/>
|
|
522
|
+
{legalConsent.communicationConsentText && (
|
|
523
|
+
<p className="text-ods-text-secondary text-h6">{legalConsent.communicationConsentText}</p>
|
|
524
|
+
)}
|
|
286
525
|
{legalConsent.privacyPolicyText && (
|
|
287
526
|
<p className="text-ods-text-secondary text-h6">{legalConsent.privacyPolicyText}</p>
|
|
288
527
|
)}
|
|
@@ -293,11 +532,43 @@ export function BookingForm({
|
|
|
293
532
|
{/* Step navigation back to the calendar lives in the step header (the
|
|
294
533
|
app-standard BackButton, rendered by the parent) — the form ships
|
|
295
534
|
only its submit. */}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
535
|
+
{/* Two shapes, not one with a placeholder: without a note this must stay
|
|
536
|
+
the bare `flex` row it has always been, or the submit slides from the
|
|
537
|
+
left edge to the right on every existing slot-first booking. */}
|
|
538
|
+
{footerNote ? (
|
|
539
|
+
<div className="flex items-center justify-between gap-[var(--spacing-system-m)]">
|
|
540
|
+
<p className="text-ods-text-secondary text-h6">{footerNote}</p>
|
|
541
|
+
{submitButton}
|
|
542
|
+
</div>
|
|
543
|
+
) : (
|
|
544
|
+
<div className="flex">{submitButton}</div>
|
|
545
|
+
)}
|
|
301
546
|
</form>
|
|
302
547
|
);
|
|
303
548
|
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Cold-start placeholder for `flow="details-first"`, where the FORM is the
|
|
552
|
+
* first thing in the action panel.
|
|
553
|
+
*
|
|
554
|
+
* The slot-first skeleton (`SlotPickerSkeleton`) would put a grey calendar
|
|
555
|
+
* where the form belongs — above the fold on a page whose entire content is
|
|
556
|
+
* this card. Same footprint discipline as its sibling: fixed heights, no shift
|
|
557
|
+
* when the real form swaps in.
|
|
558
|
+
*/
|
|
559
|
+
export function BookingFormSkeleton() {
|
|
560
|
+
return (
|
|
561
|
+
<div className="flex flex-1 flex-col gap-[var(--spacing-system-l)] p-[var(--spacing-system-l)] lg:p-0">
|
|
562
|
+
<div className="grid grid-cols-1 gap-[var(--spacing-system-m)] md:grid-cols-2">
|
|
563
|
+
<Skeleton className="h-[4.75rem] w-full" />
|
|
564
|
+
<Skeleton className="h-[4.75rem] w-full" />
|
|
565
|
+
</div>
|
|
566
|
+
<Skeleton className="h-[4.75rem] w-full" />
|
|
567
|
+
<Skeleton className="h-[7.75rem] w-full" />
|
|
568
|
+
<Skeleton className="h-[4.25rem] w-full" />
|
|
569
|
+
<div className="flex justify-end">
|
|
570
|
+
<Skeleton className="h-12 w-40" />
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|