@flamingo-stack/openframe-frontend-core 0.0.742 → 0.0.744
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/build/export-map.json +1 -1
- package/dist/{chunk-54EA4RLX.cjs → chunk-2JRCJV3M.cjs} +3 -3
- package/dist/{chunk-ZIFYSLIM.cjs → chunk-2UBR53AN.cjs} +4 -4
- package/dist/{chunk-Z2DQYKZA.cjs → chunk-35IQLGV3.cjs} +69 -69
- package/dist/{chunk-WIHHA54J.cjs → chunk-4A6OF4HI.cjs} +44 -44
- package/dist/{chunk-OBODY7F3.cjs → chunk-4N7KGOBS.cjs} +3 -3
- package/dist/{chunk-HIRVNBX5.cjs → chunk-65LJNDR2.cjs} +16 -16
- package/dist/{chunk-32Q7K5OP.cjs → chunk-7YDF3QBN.cjs} +116 -115
- package/dist/{chunk-DNFSMVXJ.cjs → chunk-AT6ZI4CO.cjs} +12 -12
- package/dist/{chunk-UWHCRIQQ.cjs → chunk-FTYQS6VI.cjs} +13 -13
- package/dist/{chunk-YQGDXH3M.cjs → chunk-GAGNKWOC.cjs} +2 -2
- package/dist/{chunk-L34TKFTS.cjs → chunk-H62FNZGG.cjs} +16 -16
- package/dist/{chunk-T433HSL6.cjs → chunk-HHL23RFC.cjs} +33 -33
- package/dist/{chunk-4PKORVOX.cjs → chunk-HJ4KGA2M.cjs} +441 -406
- package/dist/{chunk-4LDU32OB.cjs → chunk-HMSUYWSB.cjs} +4 -4
- package/dist/{chunk-UZLRSFGG.cjs → chunk-HTQJYN47.cjs} +30 -30
- package/dist/{chunk-XAA5EZ5T.cjs → chunk-JGZEHBBD.cjs} +6 -6
- package/dist/{chunk-5MH6BRPY.cjs → chunk-LJ46XYYP.cjs} +24 -12
- package/dist/{chunk-ISJXKKYW.cjs → chunk-NURAL5HD.cjs} +11 -11
- package/dist/{chunk-PS6T65KA.cjs → chunk-OC2LBQQJ.cjs} +90 -66
- package/dist/{chunk-HDBY2URA.cjs → chunk-PYWHDPVC.cjs} +31 -31
- package/dist/{chunk-R6SCSV2I.cjs → chunk-QSO56JZ2.cjs} +81 -81
- package/dist/{chunk-3LW43GIJ.cjs → chunk-QZYF2YQ3.cjs} +4 -4
- package/dist/{chunk-BI3XBCQX.cjs → chunk-YIOWV7LS.cjs} +2 -2
- package/dist/{chunk-H3UA7UY5.cjs → chunk-YJRMCFX3.cjs} +2 -2
- package/dist/{cloud-tenants-6ICAL3HO.cjs → cloud-tenants-JPXAXSPE.cjs} +7 -7
- package/dist/components/case-studies/index.cjs +19 -19
- package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/onboarding-guide-card.js +22 -12
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
- package/dist/components/chat/tool-execution-display.js +5 -4
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.js +25 -1
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/docs/index.cjs +11 -11
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/features/index.cjs +7 -7
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts +1 -1
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts.map +1 -1
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.js +15 -13
- package/dist/components/features/scripts/schedule-form/schedule-offline-fields.d.ts.map +1 -1
- package/dist/components/features/scripts/schedule-form/schedule-offline-fields.js +9 -7
- package/dist/components/help-center-pages/index.cjs +101 -101
- package/dist/components/index.cjs +59 -57
- package/dist/components/meeting-scheduler/booking-form.d.ts +11 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/booking-form.js +15 -3
- package/dist/components/meeting-scheduler/index.cjs +408 -114
- package/dist/components/meeting-scheduler/index.d.ts +16 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +157 -5
- package/dist/components/meeting-scheduler/modal-header.d.ts +27 -0
- package/dist/components/meeting-scheduler/modal-header.d.ts.map +1 -0
- package/dist/components/meeting-scheduler/modal-header.js +104 -0
- package/dist/components/meeting-scheduler/slot-picker.d.ts +13 -2
- package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/slot-picker.js +112 -73
- package/dist/components/navigation/index.cjs +10 -10
- package/dist/components/onboarding-guides/index.cjs +10 -10
- package/dist/components/product-demo/index.cjs +7 -7
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/shared/product-release/product-release-card.d.ts.map +1 -1
- package/dist/components/shared/product-release/product-release-card.js +2 -0
- package/dist/components/tickets/index.cjs +13 -13
- package/dist/components/ui/index.cjs +9 -7
- package/dist/components/ui/radio-group.d.ts +6 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -1
- package/dist/components/ui/radio-group.js +65 -58
- package/dist/components/ui/tab-navigation.d.ts +19 -1
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/components/ui/tab-navigation.js +32 -8
- package/dist/{devices-OFP2IBKN.cjs → devices-ONC4JOVH.cjs} +8 -8
- package/dist/{embeddable-chat-4C22MGNC.cjs → embeddable-chat-7ZEGCDVF.cjs} +71 -71
- package/dist/index.cjs +13 -11
- package/dist/{knowledge-KKBCSNUA.cjs → knowledge-G7XU5YST.cjs} +8 -8
- package/dist/{logs-X2GRNO57.cjs → logs-5B7NY4G6.cjs} +15 -15
- package/dist/{policies-35BXKB6P.cjs → policies-OYR3SLJN.cjs} +11 -11
- package/dist/{scripts-KQYVQOLU.cjs → scripts-6CWHTY6U.cjs} +12 -12
- package/dist/{software-update-ED33WSKT.cjs → software-update-AJPIY4SQ.cjs} +10 -10
- package/package.json +1 -1
- package/src/components/chat/entity-cards/onboarding-guide-card.tsx +8 -1
- package/src/components/chat/tool-execution-display.tsx +21 -10
- package/src/components/chat/utils/history-merge.ts +46 -1
- package/src/components/features/scripts/schedule-form/schedule-interval-input.tsx +20 -13
- package/src/components/features/scripts/schedule-form/schedule-offline-fields.tsx +9 -5
- package/src/components/meeting-scheduler/booking-form.tsx +29 -3
- package/src/components/meeting-scheduler/index.tsx +224 -3
- package/src/components/meeting-scheduler/modal-header.tsx +145 -0
- package/src/components/meeting-scheduler/slot-picker.tsx +72 -8
- package/src/components/shared/product-release/product-release-card.tsx +2 -0
- package/src/components/ui/radio-group.tsx +51 -36
- package/src/components/ui/tab-navigation.tsx +54 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
-
import { Fragment, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import type { FormEvent, ReactNode, Ref } from 'react';
|
|
6
6
|
import { useForm, Controller } from 'react-hook-form';
|
|
7
7
|
import type { Control, Path, UseFormRegister } from 'react-hook-form';
|
|
@@ -397,6 +397,16 @@ export interface BookingFormProps {
|
|
|
397
397
|
* appended full width. Both are deliberate — see `slotNode`/`unplacedFields`.
|
|
398
398
|
*/
|
|
399
399
|
fieldRows?: BookingFieldRow[];
|
|
400
|
+
/** The form element's id, so a host can submit it from a button outside it
|
|
401
|
+
* (`<button form={id}>`): the modal layout keeps its actions in a footer
|
|
402
|
+
* under the card. */
|
|
403
|
+
formId?: string;
|
|
404
|
+
/** Leave the submit button out: the host draws one of its own (see `formId`). */
|
|
405
|
+
hideSubmit?: boolean;
|
|
406
|
+
/** Whether the answers would pass validation as they stand, reported on every
|
|
407
|
+
* change: a host drawing its own submit keeps it disabled until this says
|
|
408
|
+
* true. The form's own button never needs it - its click validates. */
|
|
409
|
+
onValidityChange?: (valid: boolean) => void;
|
|
400
410
|
/** Per-field display overrides, keyed by field name — see `BookingFieldCopy`. */
|
|
401
411
|
fieldCopy?: Record<string, BookingFieldCopy>;
|
|
402
412
|
/** Email domains this form refuses, with the message it shows — see `DeniedEmailDomains`.
|
|
@@ -441,6 +451,9 @@ export function BookingForm({
|
|
|
441
451
|
submitLabel,
|
|
442
452
|
footerNote,
|
|
443
453
|
fieldRows,
|
|
454
|
+
formId,
|
|
455
|
+
hideSubmit = false,
|
|
456
|
+
onValidityChange,
|
|
444
457
|
fieldCopy,
|
|
445
458
|
deniedEmailDomains,
|
|
446
459
|
consent,
|
|
@@ -507,6 +520,19 @@ export function BookingForm({
|
|
|
507
520
|
const [consentError, setConsentError] = useState<string | null>(null);
|
|
508
521
|
const consentMissing = Boolean(consent) && !consented;
|
|
509
522
|
|
|
523
|
+
// Live validity for a host-drawn submit. `watch()` re-renders on every
|
|
524
|
+
// change; the parse is the same schema the submit runs, so the two can never
|
|
525
|
+
// disagree. Reported only when asked for, and only when it changes.
|
|
526
|
+
const watchedValues = watch();
|
|
527
|
+
const liveValid = onValidityChange ? schema.safeParse(watchedValues).success && !consentMissing : false;
|
|
528
|
+
const lastValidRef = useRef<boolean | null>(null);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (!onValidityChange) return;
|
|
531
|
+
if (lastValidRef.current === liveValid) return;
|
|
532
|
+
lastValidRef.current = liveValid;
|
|
533
|
+
onValidityChange(liveValid);
|
|
534
|
+
});
|
|
535
|
+
|
|
510
536
|
// The seeded availability is refetched immediately on mount, so the link's
|
|
511
537
|
// consent set can change WHILE this form is open. Reconcile rather than
|
|
512
538
|
// remount: carry each `consented` across by id and default new ids to false.
|
|
@@ -685,7 +711,7 @@ export function BookingForm({
|
|
|
685
711
|
|
|
686
712
|
// Opted out of HubSpot's collected-forms script — see HUBSPOT_DO_NOT_COLLECT_FORM_PROPS.
|
|
687
713
|
return (
|
|
688
|
-
<form onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>
|
|
714
|
+
<form id={formId} onSubmit={submit} className={FORM_STACK} noValidate {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>
|
|
689
715
|
<HoneypotField {...honeypotInputProps} />
|
|
690
716
|
|
|
691
717
|
{/* Email first, name pair below — the order the desktop and mobile mocks
|
|
@@ -806,7 +832,7 @@ export function BookingForm({
|
|
|
806
832
|
{/* Two shapes, not one with a placeholder: without a note this must stay
|
|
807
833
|
the bare `flex` row it has always been, or the submit slides from the
|
|
808
834
|
left edge to the right on every existing slot-first booking. */}
|
|
809
|
-
{footerNote ? (
|
|
835
|
+
{hideSubmit ? null : footerNote ? (
|
|
810
836
|
<div className={FOOTER_ROW_CLASS}>
|
|
811
837
|
<p className="text-ods-text-secondary text-h6">{footerNote}</p>
|
|
812
838
|
{submitButton}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* `SCHEDULER_FLOW_PRESETS` and the `detailsFirst` branches below.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
42
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
43
43
|
import type { ComponentType, ReactNode } from 'react';
|
|
44
44
|
import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
|
|
45
45
|
import { completeFormRescue } from '../../hooks/use-form-rescue';
|
|
@@ -56,10 +56,18 @@ import {
|
|
|
56
56
|
import { cn } from '../../utils/cn';
|
|
57
57
|
import { FORM_RESCUE_ATTEMPT_FIELD, type FormRescueDefinition } from '../../utils/form-rescue';
|
|
58
58
|
import { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';
|
|
59
|
+
import { BackButton } from '../layout/back-button';
|
|
59
60
|
import { Alert, AlertDescription, Button } from '../ui';
|
|
60
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
BookingForm,
|
|
63
|
+
BookingFormSkeleton,
|
|
64
|
+
DEFAULT_SUBMIT_LABEL,
|
|
65
|
+
type BookingFieldRow,
|
|
66
|
+
type BookingFormProps,
|
|
67
|
+
} from './booking-form';
|
|
61
68
|
import { Confirmation } from './confirmation';
|
|
62
69
|
import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
|
|
70
|
+
import { SchedulerModalHeader, SchedulerModalHeaderSkeleton } from './modal-header';
|
|
63
71
|
import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
|
|
64
72
|
|
|
65
73
|
/**
|
|
@@ -156,8 +164,24 @@ export interface HubSpotMeetingSchedulerProps {
|
|
|
156
164
|
/** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:
|
|
157
165
|
* omitted or `null` saves nothing. */
|
|
158
166
|
rescue?: FormRescueDefinition | null;
|
|
167
|
+
/**
|
|
168
|
+
* How the card is drawn.
|
|
169
|
+
*
|
|
170
|
+
* `'card'` (default, unchanged): the scheduler card - context panel beside
|
|
171
|
+
* (or over) the calendar, as every page embed has it.
|
|
172
|
+
* `'modal'`: the onboarding "Book an Onboarding Call" window: the host strip
|
|
173
|
+
* with the length on its right, the timezone row, then the calendar beside
|
|
174
|
+
* a single column of times, and Continue in a footer under the card (with
|
|
175
|
+
* Cancel beside it on a phone, where the window has no X). A time is
|
|
176
|
+
* CHOSEN, not submitted: Continue moves to the details form. Opt-in, so
|
|
177
|
+
* every existing embed keeps the card byte for byte; `details-first` has
|
|
178
|
+
* no modal arrangement and ignores it.
|
|
179
|
+
*/
|
|
180
|
+
layout?: SchedulerLayout;
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
export type SchedulerLayout = 'card' | 'modal';
|
|
184
|
+
|
|
161
185
|
type Step = 'slot' | 'details' | 'confirmed';
|
|
162
186
|
/** `onStageChange`'s value: a step, or the degraded box that replaces the flow. */
|
|
163
187
|
export type SchedulerStage = Step | 'unavailable';
|
|
@@ -393,6 +417,41 @@ function isNativelyBookable(availability: MeetingAvailability): boolean {
|
|
|
393
417
|
return true;
|
|
394
418
|
}
|
|
395
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The modal layout's frame: the content card and, under it, the footer row.
|
|
422
|
+
* No stated height - the window is as tall as the step (the calendar states
|
|
423
|
+
* its own, see the slot picker), and the modal around it scrolls if the form
|
|
424
|
+
* ever outgrows the screen.
|
|
425
|
+
*/
|
|
426
|
+
const MODAL_STACK_CLASS = 'flex flex-col gap-[var(--spacing-system-l)]';
|
|
427
|
+
const MODAL_CARD_CLASS = 'flex flex-col overflow-hidden rounded-md border border-ods-border bg-ods-card';
|
|
428
|
+
const MODAL_STEP_CLASS = 'flex flex-col p-[var(--spacing-system-m)]';
|
|
429
|
+
const NOOP = () => undefined;
|
|
430
|
+
|
|
431
|
+
/** The modal's form draws the name pair first and the email under it, as its design does. */
|
|
432
|
+
const MODAL_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'firstName' }, { name: 'lastName' }], [{ name: 'email' }]];
|
|
433
|
+
|
|
434
|
+
const MODAL_PRIMARY_CLASS = 'flex-1 content-md:min-w-[15.5rem] content-md:flex-none';
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The action row under the card: the step's primary on the right and, on a
|
|
438
|
+
* phone, Cancel beside it (the window has no X there). On the calendar the
|
|
439
|
+
* primary is Continue; on the form it is the form's own submit, drawn here and
|
|
440
|
+
* wired to the form by id so the actions sit under the card, not inside it.
|
|
441
|
+
*/
|
|
442
|
+
function ModalFooter({ onCancel, primary }: { onCancel?: () => void; primary: ReactNode }) {
|
|
443
|
+
return (
|
|
444
|
+
<div className="flex gap-[var(--spacing-system-l)] content-md:justify-end">
|
|
445
|
+
{onCancel && (
|
|
446
|
+
<Button variant="outline" onClick={onCancel} className="flex-1 content-md:hidden">
|
|
447
|
+
Cancel
|
|
448
|
+
</Button>
|
|
449
|
+
)}
|
|
450
|
+
{primary}
|
|
451
|
+
</div>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
396
455
|
export function HubSpotMeetingScheduler({
|
|
397
456
|
meetingId,
|
|
398
457
|
apiBaseUrl = '',
|
|
@@ -410,6 +469,7 @@ export function HubSpotMeetingScheduler({
|
|
|
410
469
|
detailsForm: DetailsForm = BookingForm,
|
|
411
470
|
detailsFormProps,
|
|
412
471
|
rescue = null,
|
|
472
|
+
layout = 'card',
|
|
413
473
|
}: HubSpotMeetingSchedulerProps) {
|
|
414
474
|
const {
|
|
415
475
|
availability,
|
|
@@ -424,6 +484,11 @@ export function HubSpotMeetingScheduler({
|
|
|
424
484
|
} = useMeetingBooking({ meetingId, apiBaseUrl, initialAvailability });
|
|
425
485
|
|
|
426
486
|
const detailsFirst = flow === 'details-first';
|
|
487
|
+
// The modal arrangement is slot-first's only: details-first keeps the card.
|
|
488
|
+
const modal = layout === 'modal' && !detailsFirst;
|
|
489
|
+
const modalFormId = useId();
|
|
490
|
+
// The modal's submit sits outside the form and follows what the form reports.
|
|
491
|
+
const [modalFormValid, setModalFormValid] = useState(false);
|
|
427
492
|
|
|
428
493
|
// Zone resolution happens POST-mount (Intl) unless the embedder pins one —
|
|
429
494
|
// the initial (server) render stays deterministic; time labels appear once
|
|
@@ -740,6 +805,25 @@ export function HubSpotMeetingScheduler({
|
|
|
740
805
|
);
|
|
741
806
|
const degradedCard = degraded("We couldn't load available call times. Please try again shortly.");
|
|
742
807
|
|
|
808
|
+
if (modal && isLoadingAvailability && !availability) {
|
|
809
|
+
return (
|
|
810
|
+
<div className={cn(MODAL_STACK_CLASS, className)}>
|
|
811
|
+
<div className={MODAL_CARD_CLASS}>
|
|
812
|
+
<SchedulerModalHeaderSkeleton />
|
|
813
|
+
<SlotPickerSkeleton monthOffset={monthOffset} layout="modal" />
|
|
814
|
+
</div>
|
|
815
|
+
<ModalFooter
|
|
816
|
+
onCancel={onBack}
|
|
817
|
+
primary={
|
|
818
|
+
<Button disabled onClick={NOOP} className={MODAL_PRIMARY_CLASS}>
|
|
819
|
+
Continue
|
|
820
|
+
</Button>
|
|
821
|
+
}
|
|
822
|
+
/>
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
743
827
|
if (isLoadingAvailability && !availability) {
|
|
744
828
|
// COLD start only — a month already in the query cache renders straight
|
|
745
829
|
// into the loaded card. Composed inside the SAME wrappers as that card
|
|
@@ -783,6 +867,142 @@ export function HubSpotMeetingScheduler({
|
|
|
783
867
|
|
|
784
868
|
// ---- the card ------------------------------------------------------------
|
|
785
869
|
|
|
870
|
+
if (modal) {
|
|
871
|
+
// slot-first only, so the form's Back is the calendar and the calendar's is the host's exit.
|
|
872
|
+
const modalBack = step === 'details' ? backToSlot : onBack;
|
|
873
|
+
const onSelectDuration = (ms: number) => {
|
|
874
|
+
setDurationMs(ms);
|
|
875
|
+
setSelectedDay(null);
|
|
876
|
+
setSelectedSlot(null);
|
|
877
|
+
};
|
|
878
|
+
const summary =
|
|
879
|
+
selectedSlot != null && durationMs != null && timezone != null
|
|
880
|
+
? `${formatDateWithTimezone(selectedSlot, timezone, 'weekdayDateTimeZoned', { viewerLocale: true })} · ${formatDurationCompact(durationMs / 1000)}`
|
|
881
|
+
: null;
|
|
882
|
+
return (
|
|
883
|
+
<div className={cn(MODAL_STACK_CLASS, className)}>
|
|
884
|
+
<div className={MODAL_CARD_CLASS}>
|
|
885
|
+
{/* The window's one back edge: on the form it returns to the
|
|
886
|
+
calendar at every width; on the calendar it is the host's exit,
|
|
887
|
+
drawn on a phone only - the window has an X everywhere else. */}
|
|
888
|
+
{modalBack && (
|
|
889
|
+
<BackButton
|
|
890
|
+
onClick={modalBack}
|
|
891
|
+
className={cn(
|
|
892
|
+
'px-[var(--spacing-system-m)] pt-[var(--spacing-system-m)]',
|
|
893
|
+
step === 'slot' && 'content-md:hidden',
|
|
894
|
+
)}
|
|
895
|
+
/>
|
|
896
|
+
)}
|
|
897
|
+
<SchedulerModalHeader
|
|
898
|
+
hosts={shownHosts}
|
|
899
|
+
durationsMs={durations}
|
|
900
|
+
selectedDurationMs={durationMs}
|
|
901
|
+
onSelectDuration={onSelectDuration}
|
|
902
|
+
timezone={timezone}
|
|
903
|
+
onTimezoneChange={setTimezone}
|
|
904
|
+
locked={step !== 'slot'}
|
|
905
|
+
// The zone is chosen with the time; the form restates it in its
|
|
906
|
+
// summary line, so the picker is the calendar's only.
|
|
907
|
+
showTimezone={step === 'slot'}
|
|
908
|
+
/>
|
|
909
|
+
{step === 'confirmed' && confirmation && timezone ? (
|
|
910
|
+
<div className={MODAL_STEP_CLASS}>
|
|
911
|
+
<Confirmation confirmation={confirmation} timezone={timezone} />
|
|
912
|
+
</div>
|
|
913
|
+
) : step === 'details' && durationMs != null && selectedSlot != null && timezone ? (
|
|
914
|
+
<div className={cn(MODAL_STEP_CLASS, 'gap-[var(--spacing-system-m)]')}>
|
|
915
|
+
{summary && <p className="text-ods-text-primary text-h4">{summary}</p>}
|
|
916
|
+
<DetailsForm
|
|
917
|
+
fieldRows={MODAL_FIELD_ROWS}
|
|
918
|
+
{...detailsFormProps}
|
|
919
|
+
availability={availability}
|
|
920
|
+
meetingId={meetingId}
|
|
921
|
+
startTimeMs={selectedSlot}
|
|
922
|
+
durationMs={durationMs}
|
|
923
|
+
timezone={timezone}
|
|
924
|
+
formId={modalFormId}
|
|
925
|
+
hideSubmit
|
|
926
|
+
onValidityChange={setModalFormValid}
|
|
927
|
+
isSubmitting={isSubmitting}
|
|
928
|
+
onSubmit={handleSubmit}
|
|
929
|
+
honeypotInputProps={honeypotInputProps}
|
|
930
|
+
getSignals={getSignals}
|
|
931
|
+
rescue={rescue}
|
|
932
|
+
/>
|
|
933
|
+
</div>
|
|
934
|
+
) : (
|
|
935
|
+
<>
|
|
936
|
+
{bookingError === 'SLOT_TAKEN' && (
|
|
937
|
+
<Alert variant="warning" className="mx-[var(--spacing-system-m)] mt-[var(--spacing-system-m)] w-auto">
|
|
938
|
+
<AlertDescription>
|
|
939
|
+
That time was just taken — pick another slot below. (If you already submitted, check your email for
|
|
940
|
+
a confirmation before rebooking.)
|
|
941
|
+
</AlertDescription>
|
|
942
|
+
</Alert>
|
|
943
|
+
)}
|
|
944
|
+
{timezone && durationMs != null ? (
|
|
945
|
+
<SlotPicker
|
|
946
|
+
layout="modal"
|
|
947
|
+
slots={slots}
|
|
948
|
+
timezone={timezone}
|
|
949
|
+
monthOffset={monthOffset}
|
|
950
|
+
onMonthOffsetChange={o => {
|
|
951
|
+
setSelectedDay(null);
|
|
952
|
+
setSelectedSlot(null);
|
|
953
|
+
setMonthOffset(o);
|
|
954
|
+
}}
|
|
955
|
+
selectedSlot={selectedSlot}
|
|
956
|
+
onSelectSlot={setSelectedSlot}
|
|
957
|
+
selectedDay={selectedDay}
|
|
958
|
+
onSelectDay={setSelectedDay}
|
|
959
|
+
isLoading={isLoadingAvailability}
|
|
960
|
+
/>
|
|
961
|
+
) : durations.length === 0 && !isFetchingAvailability ? (
|
|
962
|
+
<div className={cn(MODAL_STEP_CLASS, 'items-start gap-[var(--spacing-system-m)]')}>
|
|
963
|
+
<p className="text-ods-text-secondary text-h6">No call times are published right now.</p>
|
|
964
|
+
{escapeHatch}
|
|
965
|
+
</div>
|
|
966
|
+
) : (
|
|
967
|
+
<SlotPickerSkeleton monthOffset={monthOffset} layout="modal" />
|
|
968
|
+
)}
|
|
969
|
+
</>
|
|
970
|
+
)}
|
|
971
|
+
</div>
|
|
972
|
+
{step === 'slot' && (
|
|
973
|
+
<ModalFooter
|
|
974
|
+
onCancel={onBack}
|
|
975
|
+
primary={
|
|
976
|
+
<Button
|
|
977
|
+
disabled={selectedSlot == null || durationMs == null}
|
|
978
|
+
onClick={() => setStep('details')}
|
|
979
|
+
className={MODAL_PRIMARY_CLASS}
|
|
980
|
+
>
|
|
981
|
+
Continue
|
|
982
|
+
</Button>
|
|
983
|
+
}
|
|
984
|
+
/>
|
|
985
|
+
)}
|
|
986
|
+
{step === 'details' && (
|
|
987
|
+
<ModalFooter
|
|
988
|
+
onCancel={onBack}
|
|
989
|
+
primary={
|
|
990
|
+
<Button
|
|
991
|
+
type="submit"
|
|
992
|
+
form={modalFormId}
|
|
993
|
+
loading={isSubmitting}
|
|
994
|
+
disabled={isSubmitting || !modalFormValid}
|
|
995
|
+
className={MODAL_PRIMARY_CLASS}
|
|
996
|
+
>
|
|
997
|
+
{preset.submitLabel}
|
|
998
|
+
</Button>
|
|
999
|
+
}
|
|
1000
|
+
/>
|
|
1001
|
+
)}
|
|
1002
|
+
</div>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
786
1006
|
return (
|
|
787
1007
|
<div className={cardClass}>
|
|
788
1008
|
<div className={innerClass}>
|
|
@@ -912,7 +1132,8 @@ export function HubSpotMeetingScheduler({
|
|
|
912
1132
|
onSelectSlot={ms => {
|
|
913
1133
|
if (!detailsFirst) {
|
|
914
1134
|
setSelectedSlot(ms);
|
|
915
|
-
|
|
1135
|
+
// The modal's Continue is what moves on; the card moves at the click.
|
|
1136
|
+
if (!modal) setStep('details');
|
|
916
1137
|
return;
|
|
917
1138
|
}
|
|
918
1139
|
// details-first: the slot IS the submit. The step stays
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { MeetingHost } from '../../schemas/meeting-booking-schema';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { formatDurationCompact } from '../../utils/format';
|
|
6
|
+
import { SquareAvatar, AvatarStack, Button, Skeleton } from '../ui';
|
|
7
|
+
import { TimezoneSelect } from '../ui/timezone-select';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The head of the scheduler's MODAL layout: the host strip (who, and how long
|
|
11
|
+
* on its right - chips when the link offers several lengths, the length alone
|
|
12
|
+
* otherwise) and the timezone row under it, each cut off by a rule. The
|
|
13
|
+
* context panel's facts in the window's own arrangement: there is no side
|
|
14
|
+
* column in a 600px box, so the strip sits over the calendar and the zone
|
|
15
|
+
* picker spans the width above it, at every size.
|
|
16
|
+
*/
|
|
17
|
+
export interface SchedulerModalHeaderProps {
|
|
18
|
+
hosts: MeetingHost[];
|
|
19
|
+
durationsMs: number[];
|
|
20
|
+
selectedDurationMs: number | null;
|
|
21
|
+
onSelectDuration: (ms: number) => void;
|
|
22
|
+
/** Resolved IANA zone (null until the client resolves it). */
|
|
23
|
+
timezone: string | null;
|
|
24
|
+
onTimezoneChange?: (tz: string) => void;
|
|
25
|
+
/** A slot is chosen: the length is settled, the chips give way to the line. */
|
|
26
|
+
locked?: boolean;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Whether the timezone row renders (not on the confirmation). */
|
|
29
|
+
showTimezone?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ROW_CLASS = 'border-b border-ods-border p-[var(--spacing-system-m)]';
|
|
33
|
+
const STRIP_CLASS = cn(ROW_CLASS, 'flex items-center justify-between gap-[var(--spacing-system-m)]');
|
|
34
|
+
const FIELD_LABEL_CLASS = 'text-h4 text-ods-text-primary';
|
|
35
|
+
|
|
36
|
+
function HostIdentity({ hosts }: { hosts: MeetingHost[] }) {
|
|
37
|
+
if (hosts.length === 0) return null;
|
|
38
|
+
if (hosts.length > 3) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
|
|
41
|
+
<AvatarStack people={hosts} max={4} size="lg" label="Hosts" />
|
|
42
|
+
<p className="text-ods-text-secondary text-h6">{hosts.length} hosts on this calendar</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex min-w-0 flex-col gap-[var(--spacing-system-s)]">
|
|
48
|
+
{hosts.map(host => (
|
|
49
|
+
<div key={host.name} className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
50
|
+
<SquareAvatar
|
|
51
|
+
variant="round"
|
|
52
|
+
size="sm"
|
|
53
|
+
src={host.avatarUrl ?? undefined}
|
|
54
|
+
alt={host.name}
|
|
55
|
+
fallback={host.name}
|
|
56
|
+
/>
|
|
57
|
+
<div className="flex min-w-0 flex-col">
|
|
58
|
+
<p className="truncate text-ods-text-primary text-h4">{host.name}</p>
|
|
59
|
+
{host.title && <p className="truncate text-ods-text-secondary text-h6">{host.title}</p>}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function SchedulerModalHeader({
|
|
68
|
+
hosts,
|
|
69
|
+
durationsMs,
|
|
70
|
+
selectedDurationMs,
|
|
71
|
+
onSelectDuration,
|
|
72
|
+
timezone,
|
|
73
|
+
onTimezoneChange,
|
|
74
|
+
locked = false,
|
|
75
|
+
disabled = false,
|
|
76
|
+
showTimezone = true,
|
|
77
|
+
}: SchedulerModalHeaderProps) {
|
|
78
|
+
const chips = !locked && durationsMs.length > 1;
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<div className={STRIP_CLASS}>
|
|
82
|
+
<HostIdentity hosts={hosts} />
|
|
83
|
+
{chips ? (
|
|
84
|
+
<div className="flex shrink-0 flex-wrap justify-end gap-[var(--spacing-system-xs)]">
|
|
85
|
+
{durationsMs.map(ms => (
|
|
86
|
+
<Button
|
|
87
|
+
key={ms}
|
|
88
|
+
variant={selectedDurationMs === ms ? undefined : 'outline'}
|
|
89
|
+
size="small-legacy"
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
onClick={() => onSelectDuration(ms)}
|
|
92
|
+
>
|
|
93
|
+
{formatDurationCompact(ms / 1000)}
|
|
94
|
+
</Button>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
) : (
|
|
98
|
+
selectedDurationMs != null && (
|
|
99
|
+
<p className="shrink-0 text-ods-text-secondary text-h6">
|
|
100
|
+
{formatDurationCompact(selectedDurationMs / 1000)} call
|
|
101
|
+
</p>
|
|
102
|
+
)
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
{showTimezone && (
|
|
106
|
+
<div className={cn(ROW_CLASS, 'flex flex-col gap-[var(--spacing-system-xxs)]')}>
|
|
107
|
+
<p className={FIELD_LABEL_CLASS}>Timezone</p>
|
|
108
|
+
{timezone ? (
|
|
109
|
+
<TimezoneSelect
|
|
110
|
+
value={timezone}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
onChange={tz => {
|
|
113
|
+
if (tz) onTimezoneChange?.(tz);
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
) : (
|
|
117
|
+
<Skeleton className="h-12 w-full" />
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Same footprint as the loaded header, for the cold load. */
|
|
126
|
+
export function SchedulerModalHeaderSkeleton() {
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
<div className={STRIP_CLASS}>
|
|
130
|
+
<div className="flex items-center gap-[var(--spacing-system-xs)]">
|
|
131
|
+
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
|
132
|
+
<div className="flex flex-col gap-[var(--spacing-system-xxs)]">
|
|
133
|
+
<Skeleton className="h-5 w-32" />
|
|
134
|
+
<Skeleton className="h-4 w-24" />
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<Skeleton className="h-4 w-20 shrink-0" />
|
|
138
|
+
</div>
|
|
139
|
+
<div className={cn(ROW_CLASS, 'flex flex-col gap-[var(--spacing-system-xxs)]')}>
|
|
140
|
+
<p className={FIELD_LABEL_CLASS}>Timezone</p>
|
|
141
|
+
<Skeleton className="h-12 w-full" />
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -44,9 +44,20 @@ import { Button, DatePickerCalendar, Skeleton } from '../ui';
|
|
|
44
44
|
* itself, keeping SSR output deterministic).
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* How the two columns sit. `card` is the scheduler card's own (the module
|
|
49
|
+
* docblock). `modal` is the onboarding "Book an Onboarding Call" window: a
|
|
50
|
+
* 600px box where the calendar keeps its width and the times run down ONE
|
|
51
|
+
* column beside it, scrolling inside the calendar's height - on a phone the
|
|
52
|
+
* times are four across, as that design draws them.
|
|
53
|
+
*/
|
|
54
|
+
export type SlotPickerLayout = 'card' | 'modal';
|
|
55
|
+
|
|
47
56
|
export interface SlotPickerProps {
|
|
48
57
|
/** Bookable slot start times (epoch ms) for the selected duration. */
|
|
49
58
|
slots: number[];
|
|
59
|
+
/** Column layout; `card` unless the host says otherwise. */
|
|
60
|
+
layout?: SlotPickerLayout;
|
|
50
61
|
/** IANA zone every label renders in (parent resolves post-mount). */
|
|
51
62
|
timezone: string;
|
|
52
63
|
monthOffset: number;
|
|
@@ -207,6 +218,26 @@ const COLUMN_BOX = 'p-[var(--spacing-system-l)] content-lg:p-0';
|
|
|
207
218
|
/** The rule between the two, wherever the layout puts it. */
|
|
208
219
|
const COLUMN_DIVIDER = 'border-b border-ods-border content-md:border-b-0 content-md:border-r content-lg:border-0';
|
|
209
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The modal layout's classes: the same two columns, side by side from `md`
|
|
223
|
+
* only (the modal never reaches the desktop card's `lg` arrangement), the
|
|
224
|
+
* calendar at the card's tablet width and the rule between them at every
|
|
225
|
+
* width from `md`. The times column states the calendar's height
|
|
226
|
+
* (`MODAL_TIMES_H`) and scrolls inside it, so the window is as tall as the
|
|
227
|
+
* month whatever the day offers. On a phone the chips are four across and
|
|
228
|
+
* the column keeps the card's own three-row window.
|
|
229
|
+
*/
|
|
230
|
+
const MODAL_ROW_CLASS = 'flex min-w-0 flex-col content-md:flex-row content-md:items-start';
|
|
231
|
+
const MODAL_CALENDAR_W = 'content-md:w-[19rem] content-md:shrink-0';
|
|
232
|
+
const MODAL_COLUMN_BOX = 'p-[var(--spacing-system-m)]';
|
|
233
|
+
const MODAL_COLUMN_DIVIDER = 'border-b border-ods-border content-md:border-b-0 content-md:border-r';
|
|
234
|
+
/** The calendar column's height: its heading, the month header and six week rows. */
|
|
235
|
+
const MODAL_TIMES_H = 'content-md:h-[21.5rem]';
|
|
236
|
+
const MODAL_CHIP_GRID_CLASS = cn(
|
|
237
|
+
'grid grid-cols-4 content-start gap-[var(--spacing-system-xs)] overflow-y-auto content-md:grid-cols-1',
|
|
238
|
+
'max-h-[9.75rem] min-h-0 flex-1 content-md:max-h-none',
|
|
239
|
+
);
|
|
240
|
+
|
|
210
241
|
/** Deterministic month caption for a given offset (fixed en-US locale so
|
|
211
242
|
* the SSR and client first-paint markup agree byte-for-byte). */
|
|
212
243
|
export function monthLabelFor(offset: number): string {
|
|
@@ -285,10 +316,23 @@ function DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDa
|
|
|
285
316
|
* the times column is a skeleton, and it is the same one the loaded picker
|
|
286
317
|
* shows while a month loads, so there is no swap between the two.
|
|
287
318
|
*/
|
|
288
|
-
export function SlotPickerSkeleton({
|
|
319
|
+
export function SlotPickerSkeleton({
|
|
320
|
+
monthOffset = 0,
|
|
321
|
+
layout = 'card',
|
|
322
|
+
}: {
|
|
323
|
+
monthOffset?: number;
|
|
324
|
+
layout?: SlotPickerLayout;
|
|
325
|
+
}) {
|
|
326
|
+
const modal = layout === 'modal';
|
|
289
327
|
return (
|
|
290
|
-
<div className={SLOT_ROW_CLASS}>
|
|
291
|
-
<div
|
|
328
|
+
<div className={modal ? MODAL_ROW_CLASS : SLOT_ROW_CLASS}>
|
|
329
|
+
<div
|
|
330
|
+
className={
|
|
331
|
+
modal
|
|
332
|
+
? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_COLUMN_DIVIDER, MODAL_CALENDAR_W)
|
|
333
|
+
: cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')
|
|
334
|
+
}
|
|
335
|
+
>
|
|
292
336
|
{/* Needs no data — the heading renders REAL in every loading state. */}
|
|
293
337
|
<p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
|
|
294
338
|
<DayCalendar
|
|
@@ -299,7 +343,13 @@ export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }
|
|
|
299
343
|
slotsByDay={null}
|
|
300
344
|
/>
|
|
301
345
|
</div>
|
|
302
|
-
<div
|
|
346
|
+
<div
|
|
347
|
+
className={
|
|
348
|
+
modal
|
|
349
|
+
? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_TIMES_H, 'min-w-0 content-md:flex-1')
|
|
350
|
+
: cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')
|
|
351
|
+
}
|
|
352
|
+
>
|
|
303
353
|
<TimeChipsSkeleton />
|
|
304
354
|
</div>
|
|
305
355
|
</div>
|
|
@@ -330,6 +380,7 @@ export function TimeChipsSkeleton() {
|
|
|
330
380
|
|
|
331
381
|
export function SlotPicker({
|
|
332
382
|
slots,
|
|
383
|
+
layout = 'card',
|
|
333
384
|
timezone,
|
|
334
385
|
monthOffset,
|
|
335
386
|
onMonthOffsetChange,
|
|
@@ -365,9 +416,16 @@ export function SlotPicker({
|
|
|
365
416
|
const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
366
417
|
const monthHasSlots = [...slotsByDay.keys()].some(key => key.startsWith(monthPrefix));
|
|
367
418
|
|
|
419
|
+
const modal = layout === 'modal';
|
|
368
420
|
return (
|
|
369
|
-
<div className={SLOT_ROW_CLASS}>
|
|
370
|
-
<div
|
|
421
|
+
<div className={modal ? MODAL_ROW_CLASS : SLOT_ROW_CLASS}>
|
|
422
|
+
<div
|
|
423
|
+
className={
|
|
424
|
+
modal
|
|
425
|
+
? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_COLUMN_DIVIDER, MODAL_CALENDAR_W)
|
|
426
|
+
: cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'content-md:overflow-y-auto')
|
|
427
|
+
}
|
|
428
|
+
>
|
|
371
429
|
<p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
|
|
372
430
|
{/* Stays mounted through a month load — `slotsByDay: null` is what
|
|
373
431
|
"nothing selectable yet" looks like, not an unmount. */}
|
|
@@ -379,7 +437,13 @@ export function SlotPicker({
|
|
|
379
437
|
slotsByDay={isLoading ? null : slotsByDay}
|
|
380
438
|
/>
|
|
381
439
|
</div>
|
|
382
|
-
<div
|
|
440
|
+
<div
|
|
441
|
+
className={
|
|
442
|
+
modal
|
|
443
|
+
? cn(COLUMN_STACK, MODAL_COLUMN_BOX, MODAL_TIMES_H, 'min-w-0 content-md:flex-1')
|
|
444
|
+
: cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 content-md:flex-1')
|
|
445
|
+
}
|
|
446
|
+
>
|
|
383
447
|
{isLoading ? (
|
|
384
448
|
<TimeChipsSkeleton />
|
|
385
449
|
) : selectedDay ? (
|
|
@@ -389,7 +453,7 @@ export function SlotPicker({
|
|
|
389
453
|
local instant could label an adjacent day. */}
|
|
390
454
|
{formatDateWithTimezone(dateFromDayKey(selectedDay), VIEWER_TIMEZONE, 'weekday', { viewerLocale: true })}
|
|
391
455
|
</p>
|
|
392
|
-
<div className={CHIP_GRID_CLASS}>
|
|
456
|
+
<div className={modal ? MODAL_CHIP_GRID_CLASS : CHIP_GRID_CLASS}>
|
|
393
457
|
{daySlots.map(ms => {
|
|
394
458
|
const isSelected = selectedSlot === ms;
|
|
395
459
|
return (
|
|
@@ -436,6 +436,8 @@ export function ProductReleaseCard({
|
|
|
436
436
|
// affordance, so explicit ring styles match the `<span role>` form.
|
|
437
437
|
'my-1.5 flex w-full items-start gap-3 p-2',
|
|
438
438
|
'rounded-lg border border-ods-border bg-ods-card no-underline',
|
|
439
|
+
// The play mark on a video cover shows on the card's hover.
|
|
440
|
+
'group',
|
|
439
441
|
isInteractive
|
|
440
442
|
? 'cursor-pointer transition-colors hover:border-ods-text-secondary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-1 focus-visible:ring-offset-ods-card'
|
|
441
443
|
: 'cursor-default',
|