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