@flamingo-stack/openframe-frontend-core 0.0.560 → 0.0.561-1846.5000.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-F3F3PVJ3.js → chunk-44U25GD5.js} +2 -2
- package/dist/{chunk-DCB4EBA5.cjs → chunk-4LHEQMOV.cjs} +3 -3
- package/dist/chunk-4LHEQMOV.cjs.map +1 -0
- package/dist/{chunk-IGO6GNG7.js → chunk-5AEHCGQV.js} +6 -6
- package/dist/{chunk-QUYIRF6U.cjs → chunk-AGQ4VTUX.cjs} +29 -29
- package/dist/{chunk-QUYIRF6U.cjs.map → chunk-AGQ4VTUX.cjs.map} +1 -1
- package/dist/{chunk-GMDPQ5FQ.js → chunk-B6TM3QAL.js} +4 -4
- package/dist/{chunk-GZSUDIJB.cjs → chunk-CR2CMLTQ.cjs} +4 -4
- package/dist/{chunk-GZSUDIJB.cjs.map → chunk-CR2CMLTQ.cjs.map} +1 -1
- package/dist/{chunk-MAX76H7U.cjs → chunk-D3OY4CJU.cjs} +3 -3
- package/dist/{chunk-MAX76H7U.cjs.map → chunk-D3OY4CJU.cjs.map} +1 -1
- package/dist/{chunk-WXJZULAB.cjs → chunk-DAVVVBNL.cjs} +26 -26
- package/dist/{chunk-WXJZULAB.cjs.map → chunk-DAVVVBNL.cjs.map} +1 -1
- package/dist/{chunk-XRP6VHOS.cjs → chunk-DHRJFUBR.cjs} +11 -11
- package/dist/{chunk-XRP6VHOS.cjs.map → chunk-DHRJFUBR.cjs.map} +1 -1
- package/dist/{chunk-N66ANBF6.cjs → chunk-EK346HD7.cjs} +39 -39
- package/dist/{chunk-N66ANBF6.cjs.map → chunk-EK346HD7.cjs.map} +1 -1
- package/dist/{chunk-UR2JKFIO.cjs → chunk-FSWJCGRJ.cjs} +7 -7
- package/dist/{chunk-UR2JKFIO.cjs.map → chunk-FSWJCGRJ.cjs.map} +1 -1
- package/dist/{chunk-RDFMBOSI.js → chunk-GKGQ4DWE.js} +120 -48
- package/dist/chunk-GKGQ4DWE.js.map +1 -0
- package/dist/{chunk-FEI4KQYW.cjs → chunk-GWRQ5EEI.cjs} +953 -881
- package/dist/chunk-GWRQ5EEI.cjs.map +1 -0
- package/dist/{chunk-NCU45OV4.cjs → chunk-GYC3OTBI.cjs} +9 -9
- package/dist/{chunk-NCU45OV4.cjs.map → chunk-GYC3OTBI.cjs.map} +1 -1
- package/dist/{chunk-WIPZ65KU.js → chunk-JCQUWIPT.js} +3 -3
- package/dist/{chunk-2IL7SPBP.js → chunk-KRYLVEXU.js} +2 -2
- package/dist/{chunk-YNRK3N2D.js → chunk-KWNVQZ5Y.js} +4 -4
- package/dist/{chunk-QXDEXQDI.js → chunk-MLSDBNKT.js} +2 -2
- package/dist/{chunk-65ZXR4YJ.js → chunk-NGY4KET4.js} +2 -2
- package/dist/{chunk-QKGNSXF2.js → chunk-OCJG3X4U.js} +2 -2
- package/dist/{chunk-VAAQZZHT.js → chunk-ONDI64M3.js} +2 -2
- package/dist/{chunk-BKQWCJP5.js → chunk-QGQ26NEE.js} +2 -2
- package/dist/{chunk-RSZXB6J2.js → chunk-RDKJCZHY.js} +3 -3
- package/dist/chunk-RDKJCZHY.js.map +1 -0
- package/dist/{chunk-CSXEOFJ4.cjs → chunk-SUPBFRUK.cjs} +5 -5
- package/dist/{chunk-CSXEOFJ4.cjs.map → chunk-SUPBFRUK.cjs.map} +1 -1
- package/dist/{chunk-I7RGBCG6.cjs → chunk-UJ7MSY6F.cjs} +31 -31
- package/dist/{chunk-I7RGBCG6.cjs.map → chunk-UJ7MSY6F.cjs.map} +1 -1
- package/dist/{chunk-UW42K54L.cjs → chunk-UV53Z64O.cjs} +14 -14
- package/dist/{chunk-UW42K54L.cjs.map → chunk-UV53Z64O.cjs.map} +1 -1
- package/dist/{chunk-NFMYNXFH.cjs → chunk-WCM4BGHO.cjs} +97 -97
- package/dist/{chunk-NFMYNXFH.cjs.map → chunk-WCM4BGHO.cjs.map} +1 -1
- package/dist/{chunk-57G6CG2E.js → chunk-YUAKZNK5.js} +6 -6
- package/dist/{chunk-XQJPFAA7.cjs → chunk-ZENRHE55.cjs} +62 -62
- package/dist/{chunk-XQJPFAA7.cjs.map → chunk-ZENRHE55.cjs.map} +1 -1
- package/dist/{chunk-PHIRRCPY.js → chunk-ZJ7RI62F.js} +2 -2
- package/dist/components/calendar.cjs +81 -4
- package/dist/components/calendar.cjs.map +1 -1
- package/dist/components/calendar.js +81 -4
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +5 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -1
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +4 -2
- package/dist/components/features/use-walkthrough-video.d.ts +1 -1
- package/dist/components/features/use-walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/{floating-walkthrough-video.d.ts → walkthrough-video.d.ts} +61 -7
- package/dist/components/features/walkthrough-video.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +144 -140
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +16 -12
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/context-panel.d.ts +24 -6
- package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +529 -425
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts +54 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +507 -403
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/components/meeting-scheduler/slot-picker.d.ts +35 -31
- package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/related-content/index.cjs +4 -4
- package/dist/components/related-content/index.js +3 -3
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/date-picker.d.ts +62 -1
- package/dist/components/ui/date-picker.d.ts.map +1 -1
- package/dist/components/ui/field-wrapper.d.ts +7 -0
- package/dist/components/ui/field-wrapper.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +36 -36
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +5 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -2
- package/dist/hooks/use-meeting-booking.d.ts +31 -8
- package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
- package/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
- package/src/components/features/{.floating-walkthrough-video.md → .walkthrough-video.md} +8 -5
- package/src/components/features/index.ts +1 -1
- package/src/components/features/use-walkthrough-video.ts +1 -1
- package/src/components/features/{floating-walkthrough-video.tsx → walkthrough-video.tsx} +108 -22
- package/src/components/meeting-scheduler/booking-form.tsx +60 -51
- package/src/components/meeting-scheduler/confirmation.tsx +1 -1
- package/src/components/meeting-scheduler/context-panel.tsx +166 -68
- package/src/components/meeting-scheduler/index.tsx +227 -81
- package/src/components/meeting-scheduler/slot-picker.tsx +275 -127
- package/src/components/ui/date-picker.tsx +200 -47
- package/src/components/ui/field-wrapper.tsx +9 -2
- package/src/hooks/use-meeting-booking.ts +97 -73
- package/dist/chunk-7J4DGFHZ.js +0 -88
- package/dist/chunk-7J4DGFHZ.js.map +0 -1
- package/dist/chunk-DCB4EBA5.cjs.map +0 -1
- package/dist/chunk-FEI4KQYW.cjs.map +0 -1
- package/dist/chunk-MKPJYEOR.cjs +0 -88
- package/dist/chunk-MKPJYEOR.cjs.map +0 -1
- package/dist/chunk-RDFMBOSI.js.map +0 -1
- package/dist/chunk-RSZXB6J2.js.map +0 -1
- package/dist/components/features/floating-walkthrough-video.d.ts.map +0 -1
- /package/dist/{chunk-F3F3PVJ3.js.map → chunk-44U25GD5.js.map} +0 -0
- /package/dist/{chunk-IGO6GNG7.js.map → chunk-5AEHCGQV.js.map} +0 -0
- /package/dist/{chunk-GMDPQ5FQ.js.map → chunk-B6TM3QAL.js.map} +0 -0
- /package/dist/{chunk-WIPZ65KU.js.map → chunk-JCQUWIPT.js.map} +0 -0
- /package/dist/{chunk-2IL7SPBP.js.map → chunk-KRYLVEXU.js.map} +0 -0
- /package/dist/{chunk-YNRK3N2D.js.map → chunk-KWNVQZ5Y.js.map} +0 -0
- /package/dist/{chunk-QXDEXQDI.js.map → chunk-MLSDBNKT.js.map} +0 -0
- /package/dist/{chunk-65ZXR4YJ.js.map → chunk-NGY4KET4.js.map} +0 -0
- /package/dist/{chunk-QKGNSXF2.js.map → chunk-OCJG3X4U.js.map} +0 -0
- /package/dist/{chunk-VAAQZZHT.js.map → chunk-ONDI64M3.js.map} +0 -0
- /package/dist/{chunk-BKQWCJP5.js.map → chunk-QGQ26NEE.js.map} +0 -0
- /package/dist/{chunk-57G6CG2E.js.map → chunk-YUAKZNK5.js.map} +0 -0
- /package/dist/{chunk-PHIRRCPY.js.map → chunk-ZJ7RI62F.js.map} +0 -0
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useMemo } from 'react'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { Button, Skeleton } from '../ui'
|
|
3
|
+
import { useCallback, useMemo } from 'react'
|
|
4
|
+
import { ChevronRight } from 'lucide-react'
|
|
5
|
+
import { Button, DatePickerCalendar, Skeleton } from '../ui'
|
|
7
6
|
import { cn } from '../../utils/cn'
|
|
8
7
|
import { MAX_MONTH_OFFSET } from '../../utils/hubspot-meetings-convention'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* SlotPicker — day layer + time-chip grid for the meeting scheduler.
|
|
12
11
|
*
|
|
13
|
-
* Day layer is the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
12
|
+
* Day layer is the design system's own `DatePickerCalendar`, in its `bare`
|
|
13
|
+
* variant — the SAME component behind `DatePicker`'s popover and
|
|
14
|
+
* `DateFilterMenu`, not a second day grid styled to look like it; `bare` only
|
|
15
|
+
* drops the card surface and inset it would otherwise draw inside this panel. `fromDate`/`toDate` pin
|
|
16
|
+
* navigation to [current month, current + MAX_MONTH_OFFSET] so the past is
|
|
17
|
+
* unreachable, and `disabledDays` greys out days with no bookable slot —
|
|
18
|
+
* bounds and availability are different questions, so they arrive separately.
|
|
19
|
+
* Month navigation drives the host-proxy `monthOffset`, which is why the month
|
|
20
|
+
* is CONTROLLED here rather than owned by the calendar. Time chips are plain
|
|
21
|
+
* `Button`s whose variant flips on selection — no bespoke pill.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* LOADING (no blink, ever): the two columns know different amounts, so they
|
|
24
|
+
* load differently. A CALENDAR is fully derivable from the date — which month,
|
|
25
|
+
* which weekday each cell falls on — and only WHICH DAYS ARE BOOKABLE needs
|
|
26
|
+
* the network. So it never unmounts: while a month loads it renders for real
|
|
27
|
+
* with nothing yet selectable, which is exactly true, and lights up in place.
|
|
28
|
+
* Only the times column, which genuinely has no answer until the response
|
|
29
|
+
* lands, shows a placeholder.
|
|
30
|
+
*
|
|
31
|
+
* Swapping the calendar for a skeleton was the old behaviour and it is what
|
|
32
|
+
* made paging months flash: the whole grid vanished and rebuilt to say
|
|
33
|
+
* something it already knew.
|
|
34
|
+
*
|
|
35
|
+
* Each column carries its OWN heading — "Select Date & Time" over the
|
|
36
|
+
* calendar, the chosen day over the chips. The headings live HERE rather than
|
|
37
|
+
* above the whole picker so each sits with the thing it labels, and so the
|
|
38
|
+
* skeletons reproduce them without the parent rendering a heading it cannot
|
|
39
|
+
* keep in step.
|
|
26
40
|
*
|
|
27
41
|
* All slot instants are epoch-ms; every label is rendered in `timezone`
|
|
28
42
|
* (resolved by the parent AFTER mount — this component never reads Intl
|
|
@@ -71,13 +85,120 @@ function timeLabelInZone(ms: number, timeZone: string): string {
|
|
|
71
85
|
return new Intl.DateTimeFormat(undefined, { timeZone, hour: 'numeric', minute: '2-digit' }).format(new Date(ms))
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The calendar column's width, once the layout stops handing it one: on a
|
|
90
|
+
* phone the section spans the card, from `md` up it takes a stated width and
|
|
91
|
+
* the times column gets the rest.
|
|
92
|
+
*
|
|
93
|
+
* Width no longer decides height — `DatePickerCalendar bare` is a fixed 272px
|
|
94
|
+
* block at any width — so these two numbers answer one question only: how much
|
|
95
|
+
* room the month CAPTION needs. The two DS icon buttons and their gaps take
|
|
96
|
+
* 104px of the header, and "September 2026" is ~131 at text-h4, so the grid
|
|
97
|
+
* needs ~245px of inner width before the label starts colliding with the
|
|
98
|
+
* next-month button. 304 on tablet (256 inner, this column pads itself) and
|
|
99
|
+
* 256 on the desktop card, where the panel supplies the padding.
|
|
100
|
+
*
|
|
101
|
+
* That is the trade the standard button size buys: the chrome keeps its
|
|
102
|
+
* 48px targets and the COLUMN widens to house it, rather than the buttons
|
|
103
|
+
* shrinking to fit a narrower column.
|
|
104
|
+
*/
|
|
105
|
+
const CALENDAR_W = 'md:w-[19rem] md:shrink-0 lg:w-[16rem]'
|
|
106
|
+
|
|
107
|
+
/** Column heading ("Select Date & Time" / the chosen day) — body-weight
|
|
108
|
+
* primary, 4px above its content, mirrored by both skeletons. */
|
|
109
|
+
const COLUMN_HEADING_CLASS = 'shrink-0 text-h4 text-ods-text-primary'
|
|
110
|
+
const COLUMN_STACK = 'flex min-h-0 flex-col gap-[var(--spacing-system-xxs)]'
|
|
111
|
+
|
|
112
|
+
/** Static label over the calendar. Exported so a host rendering its own
|
|
113
|
+
* chrome around the picker can stay in step with it. */
|
|
114
|
+
export const SLOT_PICKER_HEADING = 'Select Date & Time'
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Chip grid: `auto-fill` over `minmax(5.5rem, 1fr)` — as many chips per row as
|
|
118
|
+
* fit at the minimum, then the leftover width shared out between them.
|
|
119
|
+
*
|
|
120
|
+
* Both halves matter. The 5.5rem MINIMUM is the widest 12-hour label
|
|
121
|
+
* ("12:45 PM") plus the chip's padding: the mocks draw 65px chips five across
|
|
122
|
+
* a phone, but they are lettered in a 24-hour locale ("13:45") and this
|
|
123
|
+
* renders in the visitor's own — a chip that clips its own time is worse than
|
|
124
|
+
* one column fewer. The `1fr` MAXIMUM is what makes the grid reach the card's
|
|
125
|
+
* edge like the design instead of stopping short with a ragged strip of empty
|
|
126
|
+
* space beside the last column, which is what a fixed track width leaves
|
|
127
|
+
* behind at every width that is not an exact multiple of it.
|
|
128
|
+
*
|
|
129
|
+
* `auto-fill` is what keeps `1fr` safe here: the track COUNT still comes from
|
|
130
|
+
* the minimum, so a wide column answers with more chips rather than with
|
|
131
|
+
* fifteen-minute slots stretched into 200px slabs.
|
|
132
|
+
*
|
|
133
|
+
* Every track is the same width, so rows line up and nothing staggers.
|
|
134
|
+
*/
|
|
135
|
+
const CHIP_GRID_CLASS = cn(
|
|
136
|
+
'grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] content-start gap-[var(--spacing-system-xs)]',
|
|
137
|
+
'overflow-y-auto',
|
|
138
|
+
// PHONE: a stated window (~3 rows, the mock's 156px) that scrolls, rather
|
|
139
|
+
// than a list that runs the length of the day. Nothing else on the phone
|
|
140
|
+
// card has a height — the design lets each step be as tall as it needs —
|
|
141
|
+
// but a busy day is forty slots, and letting them all through turns a
|
|
142
|
+
// 700px card into a 1500px one and buries whatever the page puts below
|
|
143
|
+
// it. This is the one nested scroller worth having: a small, obviously
|
|
144
|
+
// bounded list with a visible edge, exactly as drawn.
|
|
145
|
+
'max-h-[9.75rem] md:max-h-none',
|
|
146
|
+
// No height of its own, and none borrowed from the calendar either: the card
|
|
147
|
+
// states one height, the panel and this row pass it down, and what is left
|
|
148
|
+
// after the column heading IS the scroll box. A long day scrolls inside it.
|
|
149
|
+
//
|
|
150
|
+
// `min-h-0` is the load-bearing half — a flex item's default
|
|
151
|
+
// `min-height:auto` refuses to shrink below its content, so without it the
|
|
152
|
+
// chips grow the column, then the row, then the card, and `overflow-y-auto`
|
|
153
|
+
// never gets a box smaller than its contents to scroll inside. That is the
|
|
154
|
+
// exact failure that kept this card at 404px when it was told to be 318.
|
|
155
|
+
'flex-1 min-h-0',
|
|
156
|
+
)
|
|
157
|
+
/** The track sets the width; the chip fills it, so labels can't stagger it.
|
|
158
|
+
* The tighter inline padding is what lets the track hold a 12-hour label —
|
|
159
|
+
* the Button's own is sized for prose, not for a time. */
|
|
160
|
+
const CHIP_CLASS = 'w-full px-[var(--spacing-system-xxs)]'
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The two columns, at three widths.
|
|
164
|
+
*
|
|
165
|
+
* < md phone — stacked sections, a full-bleed rule between them
|
|
166
|
+
* md tablet — side by side, the calendar at its stated width and the
|
|
167
|
+
* times taking the rest, a vertical rule between them
|
|
168
|
+
* lg desktop — side by side inside the panel's own padding, separated by
|
|
169
|
+
* a gap (the card already has a rule there: the context
|
|
170
|
+
* panel's edge)
|
|
171
|
+
*
|
|
172
|
+
* From `md` up the row takes what the card has left (`flex-1 min-h-0`) and
|
|
173
|
+
* hands each column that same definite height. The calendar is a fixed 272px
|
|
174
|
+
* block and does not scroll; the times column holds whatever the day offers
|
|
175
|
+
* and scrolls ON ITS OWN. Two boxes, one height, one scrollbar — the calendar
|
|
176
|
+
* stays put while the visitor runs down the times.
|
|
177
|
+
*
|
|
178
|
+
* Below `md` the card pins no height, so the row simply grows and the PAGE
|
|
179
|
+
* scrolls — a nested scroller inside a hand-width card traps the gesture and
|
|
180
|
+
* is worse than the thing it saves.
|
|
181
|
+
*/
|
|
182
|
+
const SLOT_ROW_CLASS = cn(
|
|
183
|
+
'flex min-w-0 flex-col md:flex-row',
|
|
184
|
+
// Default `stretch` at every width, deliberately. Stacked, that is what
|
|
185
|
+
// makes each section span the card — an `items-start` here (it was here, to
|
|
186
|
+
// stop a column growing taller than its month) collapses both sections to
|
|
187
|
+
// their intrinsic WIDTH instead, which on a phone left the calendar and the
|
|
188
|
+
// chips huddled against the left edge with half the card empty. The
|
|
189
|
+
// calendar no longer needs protecting: its height is fixed, not derived.
|
|
190
|
+
'md:min-h-0 md:flex-1 lg:gap-[var(--spacing-system-lf)]',
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
/** Shared by both columns: self-padded until the panel takes over at `lg`.
|
|
194
|
+
* Widths are per-column — the calendar states one (`CALENDAR_W`) and the
|
|
195
|
+
* times take what is left, rather than a 50/50 split that would size the
|
|
196
|
+
* month by the card's width. */
|
|
197
|
+
const COLUMN_BOX = 'p-[var(--spacing-system-l)] lg:p-0'
|
|
198
|
+
|
|
199
|
+
/** The rule between the two, wherever the layout puts it. */
|
|
200
|
+
const COLUMN_DIVIDER = 'border-b border-ods-border md:border-b-0 md:border-r lg:border-0'
|
|
79
201
|
|
|
80
|
-
const WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
|
81
202
|
|
|
82
203
|
/** Deterministic month caption for a given offset (fixed en-US locale so
|
|
83
204
|
* the SSR and client first-paint markup agree byte-for-byte). */
|
|
@@ -87,73 +208,94 @@ export function monthLabelFor(offset: number): string {
|
|
|
87
208
|
return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month)
|
|
88
209
|
}
|
|
89
210
|
|
|
211
|
+
/** Nothing is bookable yet — used while a month is still loading. */
|
|
212
|
+
const DISABLE_EVERY_DAY = () => true
|
|
213
|
+
const NOOP = () => undefined
|
|
214
|
+
|
|
215
|
+
interface DayCalendarProps {
|
|
216
|
+
monthOffset: number
|
|
217
|
+
onMonthOffsetChange: (offset: number) => void
|
|
218
|
+
selectedDay: string | null
|
|
219
|
+
onSelectDay: (dayKey: string) => void
|
|
220
|
+
/** Days that ARE bookable; `null` while the month is still loading. */
|
|
221
|
+
slotsByDay: Map<string, number[]> | null
|
|
222
|
+
}
|
|
223
|
+
|
|
90
224
|
/**
|
|
91
|
-
*
|
|
92
|
-
* month
|
|
93
|
-
*
|
|
225
|
+
* The day layer — one component for every state, which is the whole point:
|
|
226
|
+
* loading a month changes WHICH DAYS ARE ENABLED, nothing else, so the grid
|
|
227
|
+
* stays mounted and the visitor never watches it disappear and come back.
|
|
94
228
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* horizontal gaps (the real table is `border-separate border-spacing-y-1` —
|
|
98
|
-
* vertical spacing only, so cells touch horizontally) and `py-1` reproduces
|
|
99
|
-
* the table's outer border-spacing edges. Any deviation here shifts the
|
|
100
|
-
* times column on skeleton ⇄ loaded swap.
|
|
229
|
+
* `aria-busy` carries what the missing days mean to assistive tech, since
|
|
230
|
+
* visually it is just an inert month.
|
|
101
231
|
*/
|
|
102
|
-
|
|
232
|
+
function DayCalendar({ monthOffset, onMonthOffsetChange, selectedDay, onSelectDay, slotsByDay }: DayCalendarProps) {
|
|
233
|
+
const now = new Date()
|
|
234
|
+
const startMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
235
|
+
// Day-0 of the FOLLOWING month = the last day of the last bookable one.
|
|
236
|
+
// `DatePickerCalendar` takes day bounds, not month bounds, so an end date
|
|
237
|
+
// pointing at the 1st would disable the rest of that month.
|
|
238
|
+
const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET + 1, 0)
|
|
239
|
+
const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
|
|
240
|
+
|
|
241
|
+
// Stable identity: the calendar memoizes its matcher list on this value, so
|
|
242
|
+
// a fresh closure per render would rebuild it every render.
|
|
243
|
+
const dayHasNoSlots = useCallback(
|
|
244
|
+
(day: Date) => !slotsByDay?.has(calendarDayKey(day)),
|
|
245
|
+
[slotsByDay],
|
|
246
|
+
)
|
|
247
|
+
|
|
103
248
|
return (
|
|
104
|
-
<div className=
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
))}
|
|
128
|
-
</div>
|
|
129
|
-
{Array.from({ length: 6 }, (_, row) => (
|
|
130
|
-
<div key={`w-${row}`} className="grid grid-cols-7">
|
|
131
|
-
{Array.from({ length: 7 }, (_, col) => (
|
|
132
|
-
<div key={col} className="flex h-9 items-center justify-center">
|
|
133
|
-
<Skeleton className="h-9 w-9 rounded-md" />
|
|
134
|
-
</div>
|
|
135
|
-
))}
|
|
136
|
-
</div>
|
|
137
|
-
))}
|
|
138
|
-
</div>
|
|
249
|
+
<div className="flex min-h-0 flex-1 flex-col" aria-busy={slotsByDay === null}>
|
|
250
|
+
<DatePickerCalendar
|
|
251
|
+
mode="single"
|
|
252
|
+
bare
|
|
253
|
+
// CONTROLLED month: paging it is a DATA event here (the parent
|
|
254
|
+
// refetches availability for the new offset), not just a view change
|
|
255
|
+
// the calendar could own.
|
|
256
|
+
month={visibleMonth}
|
|
257
|
+
onMonthChange={(month) => {
|
|
258
|
+
const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth())
|
|
259
|
+
onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)))
|
|
260
|
+
}}
|
|
261
|
+
fromDate={startMonth}
|
|
262
|
+
toDate={endMonth}
|
|
263
|
+
selected={selectedDay ? dateFromDayKey(selectedDay) : undefined}
|
|
264
|
+
onSelect={(value) => {
|
|
265
|
+
const day = value as Date | undefined
|
|
266
|
+
if (!day) return
|
|
267
|
+
const key = calendarDayKey(day)
|
|
268
|
+
if (slotsByDay?.has(key)) onSelectDay(key)
|
|
269
|
+
}}
|
|
270
|
+
disabledDays={slotsByDay === null ? DISABLE_EVERY_DAY : dayHasNoSlots}
|
|
271
|
+
/>
|
|
139
272
|
</div>
|
|
140
273
|
)
|
|
141
274
|
}
|
|
142
275
|
|
|
143
276
|
/**
|
|
144
|
-
* The
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
277
|
+
* The slot area before the first response — the pre-mount window while the
|
|
278
|
+
* display timezone resolves, and the first availability request.
|
|
279
|
+
*
|
|
280
|
+
* The calendar half is REAL, not a placeholder: see the module docblock. Only
|
|
281
|
+
* the times column is a skeleton, and it is the same one the loaded picker
|
|
282
|
+
* shows while a month loads, so there is no swap between the two.
|
|
149
283
|
*/
|
|
150
284
|
export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {
|
|
151
285
|
return (
|
|
152
|
-
<div className=
|
|
153
|
-
<div className={cn('
|
|
154
|
-
|
|
286
|
+
<div className={SLOT_ROW_CLASS}>
|
|
287
|
+
<div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>
|
|
288
|
+
{/* Needs no data — the heading renders REAL in every loading state. */}
|
|
289
|
+
<p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
|
|
290
|
+
<DayCalendar
|
|
291
|
+
monthOffset={monthOffset}
|
|
292
|
+
onMonthOffsetChange={NOOP}
|
|
293
|
+
selectedDay={null}
|
|
294
|
+
onSelectDay={NOOP}
|
|
295
|
+
slotsByDay={null}
|
|
296
|
+
/>
|
|
155
297
|
</div>
|
|
156
|
-
<div className=
|
|
298
|
+
<div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>
|
|
157
299
|
<TimeChipsSkeleton />
|
|
158
300
|
</div>
|
|
159
301
|
</div>
|
|
@@ -164,11 +306,18 @@ export function SlotPickerSkeleton({ monthOffset = 0 }: { monthOffset?: number }
|
|
|
164
306
|
export function TimeChipsSkeleton() {
|
|
165
307
|
return (
|
|
166
308
|
<>
|
|
167
|
-
{/*
|
|
168
|
-
|
|
169
|
-
<div className=
|
|
170
|
-
|
|
171
|
-
|
|
309
|
+
{/* The day label is data — but it is held inside the REAL heading line
|
|
310
|
+
box, so the skeleton's first row sits exactly where the label lands. */}
|
|
311
|
+
<div className={COLUMN_HEADING_CLASS}>
|
|
312
|
+
<Skeleton className="inline-block h-4 w-40 align-middle" />
|
|
313
|
+
</div>
|
|
314
|
+
<div className={CHIP_GRID_CLASS}>
|
|
315
|
+
{/* Enough to fill the visible scroll box at any column count — the
|
|
316
|
+
grid's track count follows the card width, so no fixed number
|
|
317
|
+
lands flush and a partial last row is the honest shape anyway. */}
|
|
318
|
+
{Array.from({ length: 16 }, (_, i) => (
|
|
319
|
+
// h-11 md:h-12 — the loaded chip's own default-size height.
|
|
320
|
+
<Skeleton key={i} className={cn(CHIP_CLASS, 'h-11 md:h-12')} />
|
|
172
321
|
))}
|
|
173
322
|
</div>
|
|
174
323
|
</>
|
|
@@ -198,50 +347,39 @@ export function SlotPicker({
|
|
|
198
347
|
return map
|
|
199
348
|
}, [slots, timezone])
|
|
200
349
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
350
|
+
const visibleMonth = useMemo(() => {
|
|
351
|
+
const now = new Date()
|
|
352
|
+
return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
|
|
353
|
+
}, [monthOffset])
|
|
205
354
|
|
|
206
355
|
const daySlots = selectedDay ? (slotsByDay.get(selectedDay) ?? []) : []
|
|
207
356
|
|
|
357
|
+
// Whether the VISIBLE month has anything at all. HubSpot's monthOffset
|
|
358
|
+
// payloads can carry near-term slots from outside the requested month, so
|
|
359
|
+
// this asks about the grid on screen, not about the payload's size.
|
|
360
|
+
const monthPrefix = `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`
|
|
361
|
+
const monthHasSlots = [...slotsByDay.keys()].some((key) => key.startsWith(monthPrefix))
|
|
362
|
+
|
|
208
363
|
return (
|
|
209
|
-
<div className=
|
|
210
|
-
<div className={cn('
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}}
|
|
222
|
-
mode="single"
|
|
223
|
-
required
|
|
224
|
-
// CONTROLLED selection: v9 needs BOTH `selected` AND `onSelect` —
|
|
225
|
-
// with `selected` alone it goes uncontrolled (internal state
|
|
226
|
-
// seeded at mount), silently ignoring the parent's auto-selected
|
|
227
|
-
// first day. `required` disables click-to-deselect.
|
|
228
|
-
selected={selectedDay ? dateFromDayKey(selectedDay) : undefined}
|
|
229
|
-
onSelect={(day) => {
|
|
230
|
-
if (!day) return
|
|
231
|
-
const key = calendarDayKey(day)
|
|
232
|
-
if (slotsByDay.has(key)) onSelectDay(key)
|
|
233
|
-
}}
|
|
234
|
-
disabled={(day) => !slotsByDay.has(calendarDayKey(day))}
|
|
235
|
-
className="p-0"
|
|
236
|
-
/>
|
|
237
|
-
)}
|
|
364
|
+
<div className={SLOT_ROW_CLASS}>
|
|
365
|
+
<div className={cn(COLUMN_STACK, COLUMN_BOX, COLUMN_DIVIDER, CALENDAR_W, 'md:overflow-y-auto')}>
|
|
366
|
+
<p className={COLUMN_HEADING_CLASS}>{SLOT_PICKER_HEADING}</p>
|
|
367
|
+
{/* Stays mounted through a month load — `slotsByDay: null` is what
|
|
368
|
+
"nothing selectable yet" looks like, not an unmount. */}
|
|
369
|
+
<DayCalendar
|
|
370
|
+
monthOffset={monthOffset}
|
|
371
|
+
onMonthOffsetChange={onMonthOffsetChange}
|
|
372
|
+
selectedDay={selectedDay}
|
|
373
|
+
onSelectDay={onSelectDay}
|
|
374
|
+
slotsByDay={isLoading ? null : slotsByDay}
|
|
375
|
+
/>
|
|
238
376
|
</div>
|
|
239
|
-
<div className=
|
|
377
|
+
<div className={cn(COLUMN_STACK, COLUMN_BOX, 'min-w-0 md:flex-1')}>
|
|
240
378
|
{isLoading ? (
|
|
241
379
|
<TimeChipsSkeleton />
|
|
242
380
|
) : selectedDay ? (
|
|
243
381
|
<>
|
|
244
|
-
<p className=
|
|
382
|
+
<p className={COLUMN_HEADING_CLASS}>
|
|
245
383
|
{/* Format the DAY-KEY itself (plain calendar date) — re-zoning a
|
|
246
384
|
local instant could label an adjacent day. */}
|
|
247
385
|
{new Intl.DateTimeFormat(undefined, {
|
|
@@ -250,15 +388,15 @@ export function SlotPicker({
|
|
|
250
388
|
day: 'numeric',
|
|
251
389
|
}).format(dateFromDayKey(selectedDay))}
|
|
252
390
|
</p>
|
|
253
|
-
<div className=
|
|
391
|
+
<div className={CHIP_GRID_CLASS}>
|
|
254
392
|
{daySlots.map((ms) => {
|
|
255
393
|
const isSelected = selectedSlot === ms
|
|
256
394
|
return (
|
|
257
395
|
<Button
|
|
258
396
|
key={ms}
|
|
259
397
|
variant={isSelected ? undefined : 'outline'}
|
|
260
|
-
size="small-legacy"
|
|
261
398
|
onClick={() => onSelectSlot(ms)}
|
|
399
|
+
className={CHIP_CLASS}
|
|
262
400
|
>
|
|
263
401
|
{timeLabelInZone(ms, timezone)}
|
|
264
402
|
</Button>
|
|
@@ -266,16 +404,26 @@ export function SlotPicker({
|
|
|
266
404
|
})}
|
|
267
405
|
</div>
|
|
268
406
|
</>
|
|
407
|
+
) : monthHasSlots ? (
|
|
408
|
+
<p className="text-h6 text-ods-text-secondary">Pick a day to see available times.</p>
|
|
269
409
|
) : (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
410
|
+
// A fully-booked month used to just SAY "try the next one" and leave
|
|
411
|
+
// the visitor to find the chevron in the other column. The way out
|
|
412
|
+
// of a dead end belongs in the dead end.
|
|
413
|
+
<div className="flex flex-col items-start gap-[var(--spacing-system-s)]">
|
|
414
|
+
<p className="text-h6 text-ods-text-secondary">
|
|
415
|
+
No available times in {monthLabelFor(monthOffset)}.
|
|
416
|
+
</p>
|
|
417
|
+
{monthOffset < MAX_MONTH_OFFSET && (
|
|
418
|
+
<Button
|
|
419
|
+
variant="outline"
|
|
420
|
+
onClick={() => onMonthOffsetChange(monthOffset + 1)}
|
|
421
|
+
rightIcon={<ChevronRight className="size-5" />}
|
|
422
|
+
>
|
|
423
|
+
Check {monthLabelFor(monthOffset + 1)}
|
|
424
|
+
</Button>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
279
427
|
)}
|
|
280
428
|
</div>
|
|
281
429
|
</div>
|