@flamingo-stack/openframe-frontend-core 0.0.594 → 0.0.595-snapshot.20260905173123
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-HQWZ4IXU.cjs → chunk-3CH6VRJX.cjs} +24 -24
- package/dist/{chunk-HQWZ4IXU.cjs.map → chunk-3CH6VRJX.cjs.map} +1 -1
- package/dist/{chunk-LKN2KKYZ.js → chunk-4K7TCJ35.js} +6 -59
- package/dist/{chunk-LKN2KKYZ.js.map → chunk-4K7TCJ35.js.map} +1 -1
- package/dist/{chunk-YLVPQNLO.cjs → chunk-5MSHWXJ5.cjs} +170 -223
- package/dist/chunk-5MSHWXJ5.cjs.map +1 -0
- package/dist/{chunk-CCLPJSY3.cjs → chunk-73BECFSU.cjs} +15 -15
- package/dist/{chunk-CCLPJSY3.cjs.map → chunk-73BECFSU.cjs.map} +1 -1
- package/dist/{chunk-F5Q3DTP5.js → chunk-7EUE3YTC.js} +8 -8
- package/dist/{chunk-GBIC3VNA.js → chunk-7KGN7DV6.js} +6 -6
- package/dist/{chunk-YZLUOZKR.js → chunk-7Q66A4CF.js} +2 -2
- package/dist/{chunk-DRZ224RU.js → chunk-ANSZVKI2.js} +8 -8
- package/dist/{chunk-N3RHX26N.js → chunk-AZZVSEP5.js} +3 -3
- package/dist/{chunk-66I2QUFE.cjs → chunk-B26UT36N.cjs} +6 -6
- package/dist/{chunk-66I2QUFE.cjs.map → chunk-B26UT36N.cjs.map} +1 -1
- package/dist/{chunk-UBRVIVY4.cjs → chunk-B2CSYMKW.cjs} +298 -285
- package/dist/chunk-B2CSYMKW.cjs.map +1 -0
- package/dist/{chunk-ZK3AZ5YF.cjs → chunk-BTOL222V.cjs} +11 -11
- package/dist/{chunk-ZK3AZ5YF.cjs.map → chunk-BTOL222V.cjs.map} +1 -1
- package/dist/{chunk-S7VW4HVL.cjs → chunk-DODE3PD4.cjs} +49 -49
- package/dist/{chunk-S7VW4HVL.cjs.map → chunk-DODE3PD4.cjs.map} +1 -1
- package/dist/{chunk-ZZHHKE2Q.js → chunk-ECXQX4EP.js} +2 -2
- package/dist/{chunk-NEFNARFF.cjs → chunk-EEV7SSBJ.cjs} +30 -30
- package/dist/{chunk-NEFNARFF.cjs.map → chunk-EEV7SSBJ.cjs.map} +1 -1
- package/dist/{chunk-3OQIZPUX.js → chunk-EO3NEGWB.js} +4 -4
- package/dist/{chunk-MOHSA65M.js → chunk-EZM5DNII.js} +2 -2
- package/dist/{chunk-IKQVXZB5.js → chunk-GSPS6Y5U.js} +6 -6
- package/dist/{chunk-CQ35OMPJ.cjs → chunk-I2BNLWC3.cjs} +3 -3
- package/dist/{chunk-CQ35OMPJ.cjs.map → chunk-I2BNLWC3.cjs.map} +1 -1
- package/dist/{chunk-HOLK4ZMT.js → chunk-II2GBWNW.js} +4 -4
- package/dist/{chunk-PETYVGEQ.cjs → chunk-JKGVXLTT.cjs} +5 -5
- package/dist/{chunk-PETYVGEQ.cjs.map → chunk-JKGVXLTT.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-ACU7J5BL.cjs → chunk-KPE7OQF6.cjs} +84 -84
- package/dist/{chunk-ACU7J5BL.cjs.map → chunk-KPE7OQF6.cjs.map} +1 -1
- package/dist/{chunk-C4YJC5X7.cjs → chunk-KRKW63LG.cjs} +9 -9
- package/dist/{chunk-C4YJC5X7.cjs.map → chunk-KRKW63LG.cjs.map} +1 -1
- package/dist/{chunk-ZEG3ZJPY.cjs → chunk-LE5SZPMN.cjs} +33 -33
- package/dist/{chunk-ZEG3ZJPY.cjs.map → chunk-LE5SZPMN.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-EWOFXJGH.cjs → chunk-M3OQLL7D.cjs} +63 -63
- package/dist/{chunk-EWOFXJGH.cjs.map → chunk-M3OQLL7D.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-5XQMEDGE.cjs → chunk-OHJ4QOWN.cjs} +12 -12
- package/dist/{chunk-5XQMEDGE.cjs.map → chunk-OHJ4QOWN.cjs.map} +1 -1
- package/dist/{chunk-Y6I2DGKC.cjs → chunk-OMKI5T2G.cjs} +1 -1
- package/dist/chunk-OMKI5T2G.cjs.map +1 -0
- package/dist/{chunk-M5HGULIY.js → chunk-OQODJ26H.js} +5 -5
- package/dist/{chunk-MEWAFY6X.js → chunk-OT5IQMSI.js} +4 -4
- package/dist/{chunk-Y22VQDVW.js → chunk-QB244ZDI.js} +4 -4
- package/dist/{chunk-3T7ZP3TL.js → chunk-QL6ECU6Y.js} +3 -3
- package/dist/{chunk-3XDPXOMS.js → chunk-R2SZYRB2.js} +7 -7
- package/dist/{chunk-QAVT6P5F.cjs → chunk-RFA5T5MW.cjs} +28 -28
- package/dist/{chunk-QAVT6P5F.cjs.map → chunk-RFA5T5MW.cjs.map} +1 -1
- package/dist/{chunk-BTF3CNDP.js → chunk-ROQO355A.js} +1 -1
- package/dist/chunk-ROQO355A.js.map +1 -0
- package/dist/{chunk-43ADGEWL.cjs → chunk-UJAMXLA3.cjs} +40 -40
- package/dist/{chunk-43ADGEWL.cjs.map → chunk-UJAMXLA3.cjs.map} +1 -1
- package/dist/{chunk-KVNA3HU2.js → chunk-XANQTIHS.js} +3 -3
- package/dist/{chunk-PMN7WDXI.js → chunk-YUHOKPKS.js} +26 -13
- package/dist/chunk-YUHOKPKS.js.map +1 -0
- package/dist/components/case-studies/index.cjs +16 -16
- package/dist/components/case-studies/index.js +6 -6
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- 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 +18 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +18 -40
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/source-icons.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/entity-cards/dispatch.tsx +0 -57
- 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/src/utils/source-icons.ts +0 -24
- package/dist/chunk-5EM6NGBK.cjs.map +0 -1
- package/dist/chunk-A5AKGC4S.js.map +0 -1
- package/dist/chunk-BTF3CNDP.js.map +0 -1
- package/dist/chunk-PMN7WDXI.js.map +0 -1
- package/dist/chunk-UBRVIVY4.cjs.map +0 -1
- package/dist/chunk-Y6I2DGKC.cjs.map +0 -1
- package/dist/chunk-YLVPQNLO.cjs.map +0 -1
- /package/dist/{chunk-F5Q3DTP5.js.map → chunk-7EUE3YTC.js.map} +0 -0
- /package/dist/{chunk-GBIC3VNA.js.map → chunk-7KGN7DV6.js.map} +0 -0
- /package/dist/{chunk-YZLUOZKR.js.map → chunk-7Q66A4CF.js.map} +0 -0
- /package/dist/{chunk-DRZ224RU.js.map → chunk-ANSZVKI2.js.map} +0 -0
- /package/dist/{chunk-N3RHX26N.js.map → chunk-AZZVSEP5.js.map} +0 -0
- /package/dist/{chunk-ZZHHKE2Q.js.map → chunk-ECXQX4EP.js.map} +0 -0
- /package/dist/{chunk-3OQIZPUX.js.map → chunk-EO3NEGWB.js.map} +0 -0
- /package/dist/{chunk-MOHSA65M.js.map → chunk-EZM5DNII.js.map} +0 -0
- /package/dist/{chunk-IKQVXZB5.js.map → chunk-GSPS6Y5U.js.map} +0 -0
- /package/dist/{chunk-HOLK4ZMT.js.map → chunk-II2GBWNW.js.map} +0 -0
- /package/dist/{chunk-M5HGULIY.js.map → chunk-OQODJ26H.js.map} +0 -0
- /package/dist/{chunk-MEWAFY6X.js.map → chunk-OT5IQMSI.js.map} +0 -0
- /package/dist/{chunk-Y22VQDVW.js.map → chunk-QB244ZDI.js.map} +0 -0
- /package/dist/{chunk-3T7ZP3TL.js.map → chunk-QL6ECU6Y.js.map} +0 -0
- /package/dist/{chunk-3XDPXOMS.js.map → chunk-R2SZYRB2.js.map} +0 -0
- /package/dist/{chunk-KVNA3HU2.js.map → chunk-XANQTIHS.js.map} +0 -0
|
@@ -47,6 +47,14 @@ 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;
|
|
50
58
|
/** Whether the timezone picker renders. Default true: the zone governs every
|
|
51
59
|
* time on screen, the form's summary line included, so it stays reachable
|
|
52
60
|
* right up to the confirmation — where there is nothing left to re-read. */
|
|
@@ -165,6 +173,7 @@ export function SchedulerContextPanel({
|
|
|
165
173
|
onBack,
|
|
166
174
|
backLabel = 'Back',
|
|
167
175
|
locked = false,
|
|
176
|
+
fixedDuration = false,
|
|
168
177
|
showTimezone = true,
|
|
169
178
|
className,
|
|
170
179
|
}: SchedulerContextPanelProps) {
|
|
@@ -223,8 +232,9 @@ export function SchedulerContextPanel({
|
|
|
223
232
|
</div>
|
|
224
233
|
)}
|
|
225
234
|
|
|
226
|
-
{locked || durationsMs.length <= 1
|
|
227
|
-
? // Post-selection, or a link that offers one
|
|
235
|
+
{locked || fixedDuration || durationsMs.length <= 1
|
|
236
|
+
? // Post-selection, a fixed-length flow, or a link that offers one
|
|
237
|
+
// length: a static line,
|
|
228
238
|
// which is what sits beside the host on the header-strip layouts.
|
|
229
239
|
selectedDurationMs != null && (
|
|
230
240
|
<p className="shrink-0 text-ods-text-secondary text-h6">
|
|
@@ -239,7 +249,7 @@ export function SchedulerContextPanel({
|
|
|
239
249
|
|
|
240
250
|
{/* Several lengths on offer: chips, which need a full row of their
|
|
241
251
|
own at every width — so they stay OUT of the identity row. */}
|
|
242
|
-
{!locked && durationsMs.length > 1 && (
|
|
252
|
+
{!locked && !fixedDuration && durationsMs.length > 1 && (
|
|
243
253
|
<div className="flex flex-col gap-[var(--spacing-system-xxs)]">
|
|
244
254
|
<p className={FIELD_LABEL_CLASS}>Duration</p>
|
|
245
255
|
<div className="flex flex-wrap gap-[var(--spacing-system-xs)]">
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
39
|
+
import type { ComponentType } from 'react';
|
|
39
40
|
import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
|
|
40
41
|
import { useHumanitySignals } from '../../hooks/use-humanity-signals';
|
|
41
42
|
import { useMeetingBooking } from '../../hooks/use-meeting-booking';
|
|
@@ -50,7 +51,19 @@ import {
|
|
|
50
51
|
import { cn } from '../../utils/cn';
|
|
51
52
|
import { formatDurationCompact } from '../../utils/format';
|
|
52
53
|
import { Alert, AlertDescription, Button } from '../ui';
|
|
53
|
-
import { BookingForm } from './booking-form';
|
|
54
|
+
import { BookingForm, BookingFormSkeleton, type BookingFormProps } from './booking-form';
|
|
55
|
+
|
|
56
|
+
// The override surface: a host builds its `detailsForm` by re-rendering
|
|
57
|
+
// `BookingForm` with `fieldRows`, so it needs both from this entry point (the
|
|
58
|
+
// package exports `./components/meeting-scheduler`, not the file beneath it).
|
|
59
|
+
export {
|
|
60
|
+
BookingForm,
|
|
61
|
+
BookingFormSkeleton,
|
|
62
|
+
type BookingFormProps,
|
|
63
|
+
type BookingFieldRow,
|
|
64
|
+
type BookingFieldSlot,
|
|
65
|
+
type BookingFormConsent,
|
|
66
|
+
} from './booking-form';
|
|
54
67
|
import { Confirmation } from './confirmation';
|
|
55
68
|
import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
|
|
56
69
|
import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
|
|
@@ -101,10 +114,48 @@ export interface HubSpotMeetingSchedulerProps {
|
|
|
101
114
|
onBack?: () => void;
|
|
102
115
|
onBooked?: (b: BookingConfirmation) => void;
|
|
103
116
|
className?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Panel order.
|
|
119
|
+
*
|
|
120
|
+
* `'slot-first'` (default, unchanged): calendar → details → confirmed.
|
|
121
|
+
* `'details-first'`: the form comes FIRST and the slot click submits the
|
|
122
|
+
* already-validated payload. Campaign landing pages use it, where "tell us
|
|
123
|
+
* about your setup, then pick a time" is the conversion shape.
|
|
124
|
+
*
|
|
125
|
+
* This is not a cosmetic swap — see the flow-dependent branches below. It is
|
|
126
|
+
* opt-in precisely so `slot-first` stays byte-identical.
|
|
127
|
+
*/
|
|
128
|
+
flow?: 'slot-first' | 'details-first';
|
|
129
|
+
/**
|
|
130
|
+
* Replace the details panel — step ONE under `flow="details-first"`, step two
|
|
131
|
+
* otherwise — with a host-supplied form. It receives exactly what the built-in
|
|
132
|
+
* one does, so the injected form keeps the widget's contract: the deferred
|
|
133
|
+
* schema, the honeypot and elapsed-ms signals, the verbatim consent block, the
|
|
134
|
+
* `onSubmit` handoff and the `isSubmitting` lock.
|
|
135
|
+
*
|
|
136
|
+
* The intended shape is a THIN wrapper that re-renders `BookingForm` with
|
|
137
|
+
* `fieldRows` (a layout re-arrangement, all machinery reused), not a
|
|
138
|
+
* hand-written form. Anything reimplementing the contract loses the bot
|
|
139
|
+
* protection and the consent guarantees.
|
|
140
|
+
*
|
|
141
|
+
* A COMPONENT type, not a render callback: React then owns its identity, so
|
|
142
|
+
* it reconciles across steps and keeps its own state instead of remounting —
|
|
143
|
+
* and the honeypot `ref` rides through as a normal prop.
|
|
144
|
+
*
|
|
145
|
+
* Defaults to the built-in form, so every existing embed is untouched.
|
|
146
|
+
*/
|
|
147
|
+
detailsForm?: ComponentType<BookingFormProps>;
|
|
104
148
|
}
|
|
105
149
|
|
|
106
150
|
type Step = 'slot' | 'details' | 'confirmed';
|
|
107
151
|
|
|
152
|
+
/** Values collected at the details step, held until a slot picks the instant. */
|
|
153
|
+
type StashedDetails = {
|
|
154
|
+
payload: Record<string, unknown>;
|
|
155
|
+
/** Humanity signals captured while the form (and its honeypot) was mounted. */
|
|
156
|
+
signals: Record<string, string | number>;
|
|
157
|
+
};
|
|
158
|
+
|
|
108
159
|
/** Context-panel geometry — ONE definition for the loaded card and the
|
|
109
160
|
* loading skeleton, which is what keeps the two footprint-identical. */
|
|
110
161
|
const CONTEXT_PANEL_CLASS =
|
|
@@ -184,6 +235,27 @@ const PANEL_STEP_CLASS = 'p-[var(--spacing-system-l)] md:min-h-0 md:overflow-y-a
|
|
|
184
235
|
*/
|
|
185
236
|
export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
|
|
186
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The same box for `flow="details-first"`, where the tallest stage is the
|
|
240
|
+
* DETAILS FORM (email + first/last + the link's declared questions + the
|
|
241
|
+
* consent block + Continue) rather than the calendar.
|
|
242
|
+
*
|
|
243
|
+
* Still a fixed pair, not a floor: everything below the card derives a definite
|
|
244
|
+
* height from it, and a floor was already tried here — it pinned the short
|
|
245
|
+
* stages and let the tall one push the wrapper, which is the screen-shake the
|
|
246
|
+
* fixed height exists to remove.
|
|
247
|
+
*
|
|
248
|
+
* ONE number from `md` up, not two: 638px, the card both mocks draw
|
|
249
|
+
* (`4904:117130` form-only, `4904:118213` three columns). The form stage has no
|
|
250
|
+
* sidebar to stack into a header strip, so nothing about it changes between
|
|
251
|
+
* tablet and desktop, and the calendar stage fits the same box at both — the
|
|
252
|
+
* times column scrolls inside it as it always has.
|
|
253
|
+
*
|
|
254
|
+
* A host reserving the box for a details-first card reserves THIS, not the
|
|
255
|
+
* constant above.
|
|
256
|
+
*/
|
|
257
|
+
export const MEETING_SCHEDULER_DETAILS_FIRST_H = 'md:h-[39.875rem]';
|
|
258
|
+
|
|
187
259
|
/**
|
|
188
260
|
* The two-panel card's box, shared verbatim by the loading skeleton and the
|
|
189
261
|
* loaded card so neither can drift from the other.
|
|
@@ -214,7 +286,9 @@ export const MEETING_SCHEDULER_H = 'md:h-[34.375rem] lg:h-[23.75rem]';
|
|
|
214
286
|
const CARD_CLASS = cn(
|
|
215
287
|
'overflow-hidden rounded-md border border-ods-border bg-ods-card',
|
|
216
288
|
'md:flex md:flex-col',
|
|
217
|
-
|
|
289
|
+
// The height is NOT baked in any more: it is chosen per flow at each render
|
|
290
|
+
// site (`flowHeight`), because details-first needs a taller box and both
|
|
291
|
+
// constants are module scope.
|
|
218
292
|
);
|
|
219
293
|
|
|
220
294
|
/** Context strip over action panel, side by side from `lg`. `md:flex-1
|
|
@@ -231,7 +305,6 @@ const CARD_INNER_CLASS = 'flex flex-col md:min-h-0 md:flex-1 lg:h-full lg:flex-r
|
|
|
231
305
|
const CARD_DEGRADED_CLASS = cn(
|
|
232
306
|
'flex flex-col items-start gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]',
|
|
233
307
|
'md:justify-center md:overflow-y-auto',
|
|
234
|
-
MEETING_SCHEDULER_H,
|
|
235
308
|
);
|
|
236
309
|
|
|
237
310
|
/**
|
|
@@ -262,6 +335,8 @@ export function HubSpotMeetingScheduler({
|
|
|
262
335
|
onBack,
|
|
263
336
|
onBooked,
|
|
264
337
|
className,
|
|
338
|
+
flow = 'slot-first',
|
|
339
|
+
detailsForm: DetailsForm = BookingForm,
|
|
265
340
|
}: HubSpotMeetingSchedulerProps) {
|
|
266
341
|
const {
|
|
267
342
|
availability,
|
|
@@ -275,6 +350,8 @@ export function HubSpotMeetingScheduler({
|
|
|
275
350
|
isSubmitting,
|
|
276
351
|
} = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });
|
|
277
352
|
|
|
353
|
+
const detailsFirst = flow === 'details-first';
|
|
354
|
+
|
|
278
355
|
// Zone resolution happens POST-mount (Intl) unless the embedder pins one —
|
|
279
356
|
// the initial (server) render stays deterministic; time labels appear once
|
|
280
357
|
// a zone exists.
|
|
@@ -297,7 +374,20 @@ export function HubSpotMeetingScheduler({
|
|
|
297
374
|
}, [hydrated]);
|
|
298
375
|
const timezone = pickedTimezone ?? displayTimezone ?? resolvedLocalTimezone;
|
|
299
376
|
|
|
300
|
-
const [step, setStep] = useState<Step>('slot');
|
|
377
|
+
const [step, setStep] = useState<Step>(detailsFirst ? 'details' : 'slot');
|
|
378
|
+
// State, not a ref: the link-swap reset below writes it DURING RENDER, and
|
|
379
|
+
// this file's own rule forbids writing a ref there. "Frozen" means written
|
|
380
|
+
// once at Continue, not `useRef`.
|
|
381
|
+
const [stash, setStash] = useState<StashedDetails | null>(null);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* details-first step ONE is the form and nothing else — the mock
|
|
385
|
+
* (`4904:117130`) draws no context panel beside it, and paints the card on the
|
|
386
|
+
* page ground so the fields read as the raised elements. The panel (host,
|
|
387
|
+
* fixed length, timezone, and the Back edge to this form) belongs to the
|
|
388
|
+
* calendar step, exactly as slot-first has always drawn it.
|
|
389
|
+
*/
|
|
390
|
+
const formOnly = detailsFirst && step === 'details';
|
|
301
391
|
const [pickedDurationMs, setDurationMs] = useState<number | null>(null);
|
|
302
392
|
const [pickedDay, setSelectedDay] = useState<string | null>(null);
|
|
303
393
|
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
|
|
@@ -316,12 +406,16 @@ export function HubSpotMeetingScheduler({
|
|
|
316
406
|
const [machineFor, setMachineFor] = useState(meetingId);
|
|
317
407
|
if (machineFor !== meetingId) {
|
|
318
408
|
setMachineFor(meetingId);
|
|
319
|
-
setStep('slot');
|
|
409
|
+
setStep(detailsFirst ? 'details' : 'slot');
|
|
320
410
|
setDurationMs(null);
|
|
321
411
|
setSelectedDay(null);
|
|
322
412
|
setSelectedSlot(null);
|
|
323
413
|
setConfirmation(null);
|
|
324
414
|
setBookingError(null);
|
|
415
|
+
// Clearing the stash is load-bearing: otherwise the swapped-in link would
|
|
416
|
+
// POST the PREVIOUS link's answers, against a schema the server rebuilds
|
|
417
|
+
// from the new link's metadata.
|
|
418
|
+
setStash(null);
|
|
325
419
|
}
|
|
326
420
|
useEffect(() => {
|
|
327
421
|
autoAdvanceCount.current = 0;
|
|
@@ -396,10 +490,53 @@ export function HubSpotMeetingScheduler({
|
|
|
396
490
|
setBookingError(null);
|
|
397
491
|
}, []);
|
|
398
492
|
|
|
493
|
+
/** details-first's inverse: back from the CALENDAR to the form. The stash is
|
|
494
|
+
* what repopulates it, so nothing is cleared here but the error. */
|
|
495
|
+
const backToDetails = useCallback(() => {
|
|
496
|
+
setStep('details');
|
|
497
|
+
setBookingError(null);
|
|
498
|
+
}, []);
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* details-first: Continue does not POST. It validates, freezes the answers
|
|
502
|
+
* and the humanity signals (captured HERE, while the form and its honeypot
|
|
503
|
+
* are still mounted — `getSignals()` reads a detached ref once they unmount),
|
|
504
|
+
* and hands over to the calendar.
|
|
505
|
+
*/
|
|
506
|
+
const stashDetails = useCallback(
|
|
507
|
+
(payload: Record<string, unknown>): Promise<void> => {
|
|
508
|
+
setBookingError(null);
|
|
509
|
+
setStash({ payload, signals: getSignals() });
|
|
510
|
+
setStep('slot');
|
|
511
|
+
// The visitor has been filling a form; the month they are about to see
|
|
512
|
+
// may have moved on, and its hop budget should start fresh.
|
|
513
|
+
autoAdvanceCount.current = 0;
|
|
514
|
+
void refetchAvailability();
|
|
515
|
+
// Deliberately NOT `async`: nothing here is awaited. The refetch is
|
|
516
|
+
// fire-and-forget so the calendar paints from cache the moment the step
|
|
517
|
+
// flips, and awaiting it would hold `BookingForm`'s isSubmitting past
|
|
518
|
+
// the unmount. `onSubmit` is Promise-returning, hence the explicit
|
|
519
|
+
// resolve rather than an empty async body (which `require-await` flags).
|
|
520
|
+
return Promise.resolve();
|
|
521
|
+
},
|
|
522
|
+
[getSignals, refetchAvailability],
|
|
523
|
+
);
|
|
524
|
+
|
|
399
525
|
const handleSubmit = useCallback(
|
|
400
526
|
async (payload: Record<string, unknown>) => {
|
|
527
|
+
// Captured BEFORE the clear below: `book()` reports a re-entrant call as
|
|
528
|
+
// a VALIDATION code carrying the message 'Already submitting', and by
|
|
529
|
+
// then this clear has already run — so a double-click would wipe a
|
|
530
|
+
// visible SLOT_TAKEN alert on its way to being ignored.
|
|
531
|
+
const priorError = bookingError;
|
|
401
532
|
setBookingError(null);
|
|
402
533
|
const result = await book(payload);
|
|
534
|
+
|
|
535
|
+
// Not an error: the hook's in-flight guard. No toast, no step change.
|
|
536
|
+
if (!result.ok && result.message === 'Already submitting') {
|
|
537
|
+
setBookingError(priorError);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
403
540
|
if (result.ok && result.confirmation) {
|
|
404
541
|
setConfirmation(result.confirmation);
|
|
405
542
|
setStep('confirmed');
|
|
@@ -413,8 +550,17 @@ export function HubSpotMeetingScheduler({
|
|
|
413
550
|
// signal so the retry isn't flagged too-fast.
|
|
414
551
|
setSelectedSlot(null);
|
|
415
552
|
setStep('slot');
|
|
416
|
-
|
|
553
|
+
// NOT in details-first: the retry re-POSTs the frozen stash, so
|
|
554
|
+
// `getSignals()` is never consulted again — but `resetSignals()` also
|
|
555
|
+
// re-stamps `startedAtRef`, which would make a LATER Continue (after a
|
|
556
|
+
// VALIDATION round-trip) read as too-fast against the min-fill floor.
|
|
557
|
+
if (!detailsFirst) resetSignals();
|
|
417
558
|
void refetchAvailability();
|
|
559
|
+
} else if (detailsFirst && (code === 'VALIDATION' || code === 'INVALID_EMAIL')) {
|
|
560
|
+
// These are DETAILS errors. In details-first the form is unmounted by
|
|
561
|
+
// now, so a toast would tell the visitor to fix an email they cannot
|
|
562
|
+
// see. Send them back to the form, which the stash repopulates.
|
|
563
|
+
setStep('details');
|
|
418
564
|
} else {
|
|
419
565
|
// The error surface is the TOAST, full stop (host-mounted Toaster —
|
|
420
566
|
// every hub platform mounts it globally; embedders must too).
|
|
@@ -425,7 +571,7 @@ export function HubSpotMeetingScheduler({
|
|
|
425
571
|
});
|
|
426
572
|
}
|
|
427
573
|
},
|
|
428
|
-
[book, onBooked, refetchAvailability, resetSignals, toast],
|
|
574
|
+
[book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],
|
|
429
575
|
);
|
|
430
576
|
|
|
431
577
|
const escapeHatch = fallbackUrl ? (
|
|
@@ -436,39 +582,82 @@ export function HubSpotMeetingScheduler({
|
|
|
436
582
|
|
|
437
583
|
// ---- terminal / degraded states -----------------------------------------
|
|
438
584
|
|
|
585
|
+
/**
|
|
586
|
+
* The card states ONE height and everything inside derives from it (see
|
|
587
|
+
* `MEETING_SCHEDULER_H`). details-first's tallest stage is the form, so it
|
|
588
|
+
* gets its own fixed pair rather than a floor — a floor was tried here before
|
|
589
|
+
* and let the tall stage push the wrapper.
|
|
590
|
+
*
|
|
591
|
+
* Placed BEFORE the host `className`: `cn` is tailwind-merge-backed and
|
|
592
|
+
* last-wins, so appending it after would make a host's own `h-*` unreachable,
|
|
593
|
+
* which is the override the height-inside-CARD_CLASS arrangement allows today.
|
|
594
|
+
*/
|
|
595
|
+
const flowHeight = detailsFirst ? MEETING_SCHEDULER_DETAILS_FIRST_H : MEETING_SCHEDULER_H;
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* details-first, server render, seed missing entirely: land on the degraded
|
|
599
|
+
* card rather than a grey calendar above the fold.
|
|
600
|
+
*
|
|
601
|
+
* Gated on `!hydrated`, NOT `!isFetchingAvailability`: React Query's
|
|
602
|
+
* optimistic-results path runs during render with nothing subscribed, so
|
|
603
|
+
* `isFetching` is TRUE on the server and that predicate would be dead exactly
|
|
604
|
+
* where it must fire. Gated on the details step too, so month paging (which
|
|
605
|
+
* only happens at `step === 'slot'`) can never reach it.
|
|
606
|
+
*/
|
|
607
|
+
const unseededDetailsFirstFailure =
|
|
608
|
+
detailsFirst && step === 'details' && !availability && !initialAvailability && !hydrated;
|
|
609
|
+
|
|
610
|
+
/** One card SHAPE for both degraded returns below — a new return, not a new
|
|
611
|
+
* design. */
|
|
612
|
+
const degradedCard = (
|
|
613
|
+
<div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
|
|
614
|
+
<p className="text-ods-text-secondary text-h6">
|
|
615
|
+
We couldn't load available call times. Please try again shortly.
|
|
616
|
+
</p>
|
|
617
|
+
{escapeHatch}
|
|
618
|
+
</div>
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
// NARROW, and above the cold-start branch on purpose. ORing this into the
|
|
622
|
+
// `availabilityError || !availability` return below (or hoisting that return
|
|
623
|
+
// up here) would swallow the cold start for EVERY flow: its predicate is
|
|
624
|
+
// strictly broader, so slot-first would answer an unseeded mount — and every
|
|
625
|
+
// month page, which is a fresh query key with no initialData — with "we
|
|
626
|
+
// couldn't load available call times" where it renders the skeleton today.
|
|
627
|
+
if (unseededDetailsFirstFailure) return degradedCard;
|
|
628
|
+
|
|
439
629
|
if (isLoadingAvailability && !availability) {
|
|
440
630
|
// COLD start only — a month already in the query cache renders straight
|
|
441
631
|
// into the loaded card. Composed inside the SAME wrappers as that card
|
|
442
632
|
// (`ContextPanelSkeleton` beside the real slot-area layout), so nothing
|
|
443
633
|
// shifts when it swaps.
|
|
444
634
|
return (
|
|
445
|
-
<div className={cn(CARD_CLASS, className)}>
|
|
635
|
+
<div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
|
|
446
636
|
<div className={CARD_INNER_CLASS}>
|
|
447
|
-
<ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />
|
|
637
|
+
{!formOnly && <ContextPanelSkeleton onBack={onBack} className={CONTEXT_PANEL_CLASS} />}
|
|
448
638
|
<div className={ACTION_PANEL_CLASS}>
|
|
449
|
-
|
|
639
|
+
{/* Both terms matter. Without `flow` this stays a calendar where
|
|
640
|
+
the form belongs; without the step term, paging a month AFTER
|
|
641
|
+
Continue (which happens at `step === 'slot'`) would swap the
|
|
642
|
+
calendar out for a form skeleton and back. */}
|
|
643
|
+
{detailsFirst && step === 'details' ? (
|
|
644
|
+
<BookingFormSkeleton />
|
|
645
|
+
) : (
|
|
646
|
+
<SlotPickerSkeleton monthOffset={monthOffset} />
|
|
647
|
+
)}
|
|
450
648
|
</div>
|
|
451
649
|
</div>
|
|
452
650
|
</div>
|
|
453
651
|
);
|
|
454
652
|
}
|
|
455
653
|
|
|
456
|
-
if (availabilityError || !availability)
|
|
457
|
-
return (
|
|
458
|
-
<div className={cn(CARD_DEGRADED_CLASS, className)}>
|
|
459
|
-
<p className="text-ods-text-secondary text-h6">
|
|
460
|
-
We couldn't load available call times. Please try again shortly.
|
|
461
|
-
</p>
|
|
462
|
-
{escapeHatch}
|
|
463
|
-
</div>
|
|
464
|
-
);
|
|
465
|
-
}
|
|
654
|
+
if (availabilityError || !availability) return degradedCard;
|
|
466
655
|
|
|
467
656
|
if (!isNativelyBookable(availability)) {
|
|
468
657
|
// Fail closed — never render a half-working native form on a link with
|
|
469
658
|
// questions or consent we can't faithfully reproduce.
|
|
470
659
|
return (
|
|
471
|
-
<div className={cn(CARD_DEGRADED_CLASS, className)}>
|
|
660
|
+
<div className={cn(CARD_DEGRADED_CLASS, flowHeight, className)}>
|
|
472
661
|
<p className="text-ods-text-secondary text-h6">This meeting type is booked directly on HubSpot.</p>
|
|
473
662
|
{escapeHatch}
|
|
474
663
|
</div>
|
|
@@ -478,36 +667,51 @@ export function HubSpotMeetingScheduler({
|
|
|
478
667
|
// ---- the card ------------------------------------------------------------
|
|
479
668
|
|
|
480
669
|
return (
|
|
481
|
-
<div className={cn(CARD_CLASS, className)}>
|
|
670
|
+
<div className={cn(CARD_CLASS, flowHeight, formOnly && 'bg-ods-bg', className)}>
|
|
482
671
|
<div className={CARD_INNER_CLASS}>
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
672
|
+
{!formOnly && (
|
|
673
|
+
<SchedulerContextPanel
|
|
674
|
+
hosts={shownHosts}
|
|
675
|
+
title={title}
|
|
676
|
+
description={description}
|
|
677
|
+
durationsMs={durations}
|
|
678
|
+
selectedDurationMs={durationMs}
|
|
679
|
+
onSelectDuration={ms => {
|
|
680
|
+
setDurationMs(ms);
|
|
681
|
+
setSelectedDay(null);
|
|
682
|
+
setSelectedSlot(null);
|
|
683
|
+
// slot-first only. In details-first `details` is step ONE, so this
|
|
684
|
+
// would yank a mid-typing visitor onto the calendar and unmount the
|
|
685
|
+
// form under them.
|
|
686
|
+
if (!detailsFirst && step === 'details') setStep('slot');
|
|
687
|
+
}}
|
|
688
|
+
timezone={timezone}
|
|
689
|
+
onTimezoneChange={setTimezone}
|
|
690
|
+
// ONE back edge for the whole card, in the panel that never swaps.
|
|
691
|
+
// Its destination is simply "the previous step": from the form back
|
|
692
|
+
// to the calendar, from the calendar out of the scheduler (if the
|
|
693
|
+
// host gave us an exit at all). Two Backs on screen reading the same
|
|
694
|
+
// word with different destinations was the ambiguity the details
|
|
695
|
+
// step used to carry.
|
|
696
|
+
// details-first inverts the mapping: the form is step ONE, so Back at
|
|
697
|
+
// `details` is the host's exit and Back at `slot` returns to the form.
|
|
698
|
+
onBack={
|
|
699
|
+
detailsFirst ? (step === 'slot' ? backToDetails : onBack) : step === 'details' ? backToSlot : onBack
|
|
700
|
+
}
|
|
701
|
+
// In details-first the duration/zone selectors stay live while the
|
|
702
|
+
// form is filled — but must freeze once a POST is in flight, or a
|
|
703
|
+
// mid-flight change clears the slot under the spinner while the body
|
|
704
|
+
// already built carries the old duration.
|
|
705
|
+
locked={detailsFirst ? step === 'confirmed' || isSubmitting : step !== 'slot'}
|
|
706
|
+
// The zone still governs every time on screen — including the
|
|
707
|
+
// summary line on the form — so the picker stays until there is
|
|
708
|
+
// nothing left to re-read in it.
|
|
709
|
+
showTimezone={step !== 'confirmed'}
|
|
710
|
+
// The mock's calendar step states the length, it does not offer it.
|
|
711
|
+
fixedDuration={detailsFirst}
|
|
712
|
+
className={CONTEXT_PANEL_CLASS}
|
|
713
|
+
/>
|
|
714
|
+
)}
|
|
511
715
|
|
|
512
716
|
{/* Scrolls INSIDE the fixed card rather than growing it — see
|
|
513
717
|
MEETING_SCHEDULER_H. `min-h-0` is what lets it: a flex item's
|
|
@@ -517,32 +721,50 @@ export function HubSpotMeetingScheduler({
|
|
|
517
721
|
<div className={cn('flex flex-1 flex-col', PANEL_STEP_CLASS)}>
|
|
518
722
|
<Confirmation confirmation={confirmation} timezone={timezone} />
|
|
519
723
|
</div>
|
|
520
|
-
) : step === 'details' &&
|
|
724
|
+
) : step === 'details' &&
|
|
725
|
+
(detailsFirst
|
|
726
|
+
? // The form is step ONE here: no slot yet, and `timezone` is null
|
|
727
|
+
// on the server render, so neither may gate the PANEL. A link
|
|
728
|
+
// publishing no durations has no form worth showing.
|
|
729
|
+
durations.length > 0
|
|
730
|
+
: durationMs != null && selectedSlot != null && timezone) ? (
|
|
521
731
|
<div className={cn('flex flex-1 flex-col gap-[var(--spacing-system-m)]', PANEL_STEP_CLASS)}>
|
|
522
732
|
{/* Top-aligned, and no back edge of its own: the ONE back edge
|
|
523
733
|
lives in the context panel at every step (see `contextBack`),
|
|
524
734
|
which is where the design puts it and the only spot that
|
|
525
|
-
stays put while this side swaps between calendar and form.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
735
|
+
stays put while this side swaps between calendar and form.
|
|
736
|
+
|
|
737
|
+
The summary keeps its OWN guard — including `timezone`, which
|
|
738
|
+
is null pre-hydration and which `Intl.DateTimeFormat` throws a
|
|
739
|
+
RangeError on rather than ignoring. */}
|
|
740
|
+
{selectedSlot != null && durationMs != null && timezone != null && (
|
|
741
|
+
<p className="text-ods-text-primary text-h4">
|
|
742
|
+
{new Intl.DateTimeFormat(undefined, {
|
|
743
|
+
timeZone: timezone,
|
|
744
|
+
weekday: 'long',
|
|
745
|
+
month: 'long',
|
|
746
|
+
day: 'numeric',
|
|
747
|
+
hour: 'numeric',
|
|
748
|
+
minute: '2-digit',
|
|
749
|
+
timeZoneName: 'short',
|
|
750
|
+
}).format(new Date(selectedSlot))}{' '}
|
|
751
|
+
· {formatDurationCompact(durationMs / 1000)}
|
|
752
|
+
</p>
|
|
753
|
+
)}
|
|
754
|
+
<DetailsForm
|
|
539
755
|
availability={availability}
|
|
540
756
|
meetingId={meetingId}
|
|
541
|
-
startTimeMs={selectedSlot}
|
|
542
|
-
durationMs={durationMs}
|
|
757
|
+
startTimeMs={selectedSlot ?? undefined}
|
|
758
|
+
durationMs={durationMs ?? undefined}
|
|
543
759
|
timezone={timezone}
|
|
760
|
+
deferSlot={detailsFirst}
|
|
761
|
+
submitLabel={detailsFirst ? 'Continue' : undefined}
|
|
762
|
+
footerNote={
|
|
763
|
+
detailsFirst ? 'Next step: pick a slot that works. Calendar invite lands right after.' : undefined
|
|
764
|
+
}
|
|
765
|
+
initialValues={detailsFirst ? stash?.payload : undefined}
|
|
544
766
|
isSubmitting={isSubmitting}
|
|
545
|
-
onSubmit={handleSubmit}
|
|
767
|
+
onSubmit={detailsFirst ? stashDetails : handleSubmit}
|
|
546
768
|
honeypotInputProps={honeypotInputProps}
|
|
547
769
|
getSignals={getSignals}
|
|
548
770
|
/>
|
|
@@ -575,12 +797,38 @@ export function HubSpotMeetingScheduler({
|
|
|
575
797
|
}}
|
|
576
798
|
selectedSlot={selectedSlot}
|
|
577
799
|
onSelectSlot={ms => {
|
|
800
|
+
if (!detailsFirst) {
|
|
801
|
+
setSelectedSlot(ms);
|
|
802
|
+
setStep('details');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// details-first: the slot IS the submit. The step stays
|
|
806
|
+
// 'slot' for the whole POST — moving it here would unmount
|
|
807
|
+
// the chip the spinner lives on and fight the SLOT_TAKEN
|
|
808
|
+
// branch, which puts the visitor back on this calendar.
|
|
809
|
+
if (isSubmitting || !stash) return;
|
|
578
810
|
setSelectedSlot(ms);
|
|
579
|
-
|
|
811
|
+
void handleSubmit({
|
|
812
|
+
...stash.payload,
|
|
813
|
+
...stash.signals,
|
|
814
|
+
meetingId,
|
|
815
|
+
// `ms`, NOT `selectedSlot` — the setState above has not
|
|
816
|
+
// committed inside this handler, so state holds null on
|
|
817
|
+
// the first booking and the PREVIOUS slot on a retry.
|
|
818
|
+
startTimeMs: ms,
|
|
819
|
+
// Live values: both stay editable after Continue, so the
|
|
820
|
+
// copies frozen at that point can be stale.
|
|
821
|
+
durationMs,
|
|
822
|
+
timezone,
|
|
823
|
+
});
|
|
580
824
|
}}
|
|
581
825
|
selectedDay={selectedDay}
|
|
582
826
|
onSelectDay={setSelectedDay}
|
|
583
827
|
isLoading={isLoadingAvailability}
|
|
828
|
+
// Distinct from `isLoading`, which swaps the whole times
|
|
829
|
+
// column for a skeleton: the grid must stay up with the
|
|
830
|
+
// clicked chip spinning.
|
|
831
|
+
isSubmitting={detailsFirst ? isSubmitting : undefined}
|
|
584
832
|
/>
|
|
585
833
|
) : durations.length === 0 && !isFetchingAvailability ? (
|
|
586
834
|
// The LINK publishes nothing at all — a different thing from a
|
|
@@ -56,6 +56,13 @@ export interface SlotPickerProps {
|
|
|
56
56
|
onSelectDay: (dayKey: string) => void;
|
|
57
57
|
/** Availability refetch in flight (month change) → per-region skeletons. */
|
|
58
58
|
isLoading?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* A booking POST is in flight (details-first, where the slot click IS the
|
|
61
|
+
* submit). DISTINCT from `isLoading`, which swaps the whole times column for
|
|
62
|
+
* a skeleton — here the grid must stay up with the clicked chip spinning, so
|
|
63
|
+
* the visitor can see WHICH time is being booked.
|
|
64
|
+
*/
|
|
65
|
+
isSubmitting?: boolean;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/** Stable per-zone day key for an instant, e.g. "2026-08-14" (exported — the
|
|
@@ -330,6 +337,7 @@ export function SlotPicker({
|
|
|
330
337
|
selectedDay,
|
|
331
338
|
onSelectDay,
|
|
332
339
|
isLoading = false,
|
|
340
|
+
isSubmitting = false,
|
|
333
341
|
}: SlotPickerProps) {
|
|
334
342
|
const slotsByDay = useMemo(() => {
|
|
335
343
|
const map = new Map<string, number[]>();
|
|
@@ -391,7 +399,15 @@ export function SlotPicker({
|
|
|
391
399
|
<Button
|
|
392
400
|
key={ms}
|
|
393
401
|
variant={isSelected ? undefined : 'outline'}
|
|
394
|
-
|
|
402
|
+
// The click is the submit in details-first, so the grid
|
|
403
|
+
// itself is the double-book guard — the form button that
|
|
404
|
+
// used to carry `disabled={isSubmitting}` is not on screen.
|
|
405
|
+
disabled={isSubmitting}
|
|
406
|
+
loading={isSubmitting && isSelected}
|
|
407
|
+
onClick={() => {
|
|
408
|
+
if (isSubmitting) return;
|
|
409
|
+
onSelectSlot(ms);
|
|
410
|
+
}}
|
|
395
411
|
className={CHIP_CLASS}
|
|
396
412
|
>
|
|
397
413
|
{timeLabelInZone(ms, timezone)}
|