@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.
Files changed (146) hide show
  1. package/dist/{chunk-F3F3PVJ3.js → chunk-44U25GD5.js} +2 -2
  2. package/dist/{chunk-DCB4EBA5.cjs → chunk-4LHEQMOV.cjs} +3 -3
  3. package/dist/chunk-4LHEQMOV.cjs.map +1 -0
  4. package/dist/{chunk-IGO6GNG7.js → chunk-5AEHCGQV.js} +6 -6
  5. package/dist/{chunk-QUYIRF6U.cjs → chunk-AGQ4VTUX.cjs} +29 -29
  6. package/dist/{chunk-QUYIRF6U.cjs.map → chunk-AGQ4VTUX.cjs.map} +1 -1
  7. package/dist/{chunk-GMDPQ5FQ.js → chunk-B6TM3QAL.js} +4 -4
  8. package/dist/{chunk-GZSUDIJB.cjs → chunk-CR2CMLTQ.cjs} +4 -4
  9. package/dist/{chunk-GZSUDIJB.cjs.map → chunk-CR2CMLTQ.cjs.map} +1 -1
  10. package/dist/{chunk-MAX76H7U.cjs → chunk-D3OY4CJU.cjs} +3 -3
  11. package/dist/{chunk-MAX76H7U.cjs.map → chunk-D3OY4CJU.cjs.map} +1 -1
  12. package/dist/{chunk-WXJZULAB.cjs → chunk-DAVVVBNL.cjs} +26 -26
  13. package/dist/{chunk-WXJZULAB.cjs.map → chunk-DAVVVBNL.cjs.map} +1 -1
  14. package/dist/{chunk-XRP6VHOS.cjs → chunk-DHRJFUBR.cjs} +11 -11
  15. package/dist/{chunk-XRP6VHOS.cjs.map → chunk-DHRJFUBR.cjs.map} +1 -1
  16. package/dist/{chunk-N66ANBF6.cjs → chunk-EK346HD7.cjs} +39 -39
  17. package/dist/{chunk-N66ANBF6.cjs.map → chunk-EK346HD7.cjs.map} +1 -1
  18. package/dist/{chunk-UR2JKFIO.cjs → chunk-FSWJCGRJ.cjs} +7 -7
  19. package/dist/{chunk-UR2JKFIO.cjs.map → chunk-FSWJCGRJ.cjs.map} +1 -1
  20. package/dist/{chunk-RDFMBOSI.js → chunk-GKGQ4DWE.js} +120 -48
  21. package/dist/chunk-GKGQ4DWE.js.map +1 -0
  22. package/dist/{chunk-FEI4KQYW.cjs → chunk-GWRQ5EEI.cjs} +953 -881
  23. package/dist/chunk-GWRQ5EEI.cjs.map +1 -0
  24. package/dist/{chunk-NCU45OV4.cjs → chunk-GYC3OTBI.cjs} +9 -9
  25. package/dist/{chunk-NCU45OV4.cjs.map → chunk-GYC3OTBI.cjs.map} +1 -1
  26. package/dist/{chunk-WIPZ65KU.js → chunk-JCQUWIPT.js} +3 -3
  27. package/dist/{chunk-2IL7SPBP.js → chunk-KRYLVEXU.js} +2 -2
  28. package/dist/{chunk-YNRK3N2D.js → chunk-KWNVQZ5Y.js} +4 -4
  29. package/dist/{chunk-QXDEXQDI.js → chunk-MLSDBNKT.js} +2 -2
  30. package/dist/{chunk-65ZXR4YJ.js → chunk-NGY4KET4.js} +2 -2
  31. package/dist/{chunk-QKGNSXF2.js → chunk-OCJG3X4U.js} +2 -2
  32. package/dist/{chunk-VAAQZZHT.js → chunk-ONDI64M3.js} +2 -2
  33. package/dist/{chunk-BKQWCJP5.js → chunk-QGQ26NEE.js} +2 -2
  34. package/dist/{chunk-RSZXB6J2.js → chunk-RDKJCZHY.js} +3 -3
  35. package/dist/chunk-RDKJCZHY.js.map +1 -0
  36. package/dist/{chunk-CSXEOFJ4.cjs → chunk-SUPBFRUK.cjs} +5 -5
  37. package/dist/{chunk-CSXEOFJ4.cjs.map → chunk-SUPBFRUK.cjs.map} +1 -1
  38. package/dist/{chunk-I7RGBCG6.cjs → chunk-UJ7MSY6F.cjs} +31 -31
  39. package/dist/{chunk-I7RGBCG6.cjs.map → chunk-UJ7MSY6F.cjs.map} +1 -1
  40. package/dist/{chunk-UW42K54L.cjs → chunk-UV53Z64O.cjs} +14 -14
  41. package/dist/{chunk-UW42K54L.cjs.map → chunk-UV53Z64O.cjs.map} +1 -1
  42. package/dist/{chunk-NFMYNXFH.cjs → chunk-WCM4BGHO.cjs} +97 -97
  43. package/dist/{chunk-NFMYNXFH.cjs.map → chunk-WCM4BGHO.cjs.map} +1 -1
  44. package/dist/{chunk-57G6CG2E.js → chunk-YUAKZNK5.js} +6 -6
  45. package/dist/{chunk-XQJPFAA7.cjs → chunk-ZENRHE55.cjs} +62 -62
  46. package/dist/{chunk-XQJPFAA7.cjs.map → chunk-ZENRHE55.cjs.map} +1 -1
  47. package/dist/{chunk-PHIRRCPY.js → chunk-ZJ7RI62F.js} +2 -2
  48. package/dist/components/calendar.cjs +81 -4
  49. package/dist/components/calendar.cjs.map +1 -1
  50. package/dist/components/calendar.js +81 -4
  51. package/dist/components/calendar.js.map +1 -1
  52. package/dist/components/case-studies/index.cjs +9 -9
  53. package/dist/components/case-studies/index.js +3 -3
  54. package/dist/components/chat/index.cjs +3 -3
  55. package/dist/components/chat/index.js +2 -2
  56. package/dist/components/contact/index.cjs +4 -4
  57. package/dist/components/contact/index.js +3 -3
  58. package/dist/components/docs/index.cjs +6 -6
  59. package/dist/components/docs/index.js +5 -5
  60. package/dist/components/embeds/index.cjs +4 -4
  61. package/dist/components/embeds/index.js +3 -3
  62. package/dist/components/faq/index.cjs +4 -4
  63. package/dist/components/faq/index.js +3 -3
  64. package/dist/components/features/index.cjs +5 -3
  65. package/dist/components/features/index.cjs.map +1 -1
  66. package/dist/components/features/index.d.ts +1 -1
  67. package/dist/components/features/index.d.ts.map +1 -1
  68. package/dist/components/features/index.js +4 -2
  69. package/dist/components/features/use-walkthrough-video.d.ts +1 -1
  70. package/dist/components/features/use-walkthrough-video.d.ts.map +1 -1
  71. package/dist/components/features/{floating-walkthrough-video.d.ts → walkthrough-video.d.ts} +61 -7
  72. package/dist/components/features/walkthrough-video.d.ts.map +1 -0
  73. package/dist/components/help-center-pages/index.cjs +23 -23
  74. package/dist/components/help-center-pages/index.js +14 -14
  75. package/dist/components/index.cjs +144 -140
  76. package/dist/components/index.cjs.map +1 -1
  77. package/dist/components/index.js +16 -12
  78. package/dist/components/index.js.map +1 -1
  79. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  80. package/dist/components/meeting-scheduler/context-panel.d.ts +24 -6
  81. package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
  82. package/dist/components/meeting-scheduler/index.cjs +529 -425
  83. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  84. package/dist/components/meeting-scheduler/index.d.ts +54 -1
  85. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  86. package/dist/components/meeting-scheduler/index.js +507 -403
  87. package/dist/components/meeting-scheduler/index.js.map +1 -1
  88. package/dist/components/meeting-scheduler/slot-picker.d.ts +35 -31
  89. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  90. package/dist/components/navigation/index.cjs +3 -3
  91. package/dist/components/navigation/index.js +2 -2
  92. package/dist/components/onboarding-guides/index.cjs +6 -6
  93. package/dist/components/onboarding-guides/index.js +5 -5
  94. package/dist/components/related-content/index.cjs +4 -4
  95. package/dist/components/related-content/index.js +3 -3
  96. package/dist/components/tickets/index.cjs +7 -7
  97. package/dist/components/tickets/index.js +6 -6
  98. package/dist/components/ui/date-picker.d.ts +62 -1
  99. package/dist/components/ui/date-picker.d.ts.map +1 -1
  100. package/dist/components/ui/field-wrapper.d.ts +7 -0
  101. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  102. package/dist/components/ui/file-manager/index.cjs +36 -36
  103. package/dist/components/ui/file-manager/index.js +1 -1
  104. package/dist/components/ui/index.cjs +5 -3
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.js +4 -2
  107. package/dist/hooks/use-meeting-booking.d.ts +31 -8
  108. package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
  109. package/dist/index.cjs +7 -3
  110. package/dist/index.cjs.map +1 -1
  111. package/dist/index.js +6 -2
  112. package/package.json +1 -1
  113. package/src/components/features/{.floating-walkthrough-video.md → .walkthrough-video.md} +8 -5
  114. package/src/components/features/index.ts +1 -1
  115. package/src/components/features/use-walkthrough-video.ts +1 -1
  116. package/src/components/features/{floating-walkthrough-video.tsx → walkthrough-video.tsx} +108 -22
  117. package/src/components/meeting-scheduler/booking-form.tsx +60 -51
  118. package/src/components/meeting-scheduler/confirmation.tsx +1 -1
  119. package/src/components/meeting-scheduler/context-panel.tsx +166 -68
  120. package/src/components/meeting-scheduler/index.tsx +227 -81
  121. package/src/components/meeting-scheduler/slot-picker.tsx +275 -127
  122. package/src/components/ui/date-picker.tsx +200 -47
  123. package/src/components/ui/field-wrapper.tsx +9 -2
  124. package/src/hooks/use-meeting-booking.ts +97 -73
  125. package/dist/chunk-7J4DGFHZ.js +0 -88
  126. package/dist/chunk-7J4DGFHZ.js.map +0 -1
  127. package/dist/chunk-DCB4EBA5.cjs.map +0 -1
  128. package/dist/chunk-FEI4KQYW.cjs.map +0 -1
  129. package/dist/chunk-MKPJYEOR.cjs +0 -88
  130. package/dist/chunk-MKPJYEOR.cjs.map +0 -1
  131. package/dist/chunk-RDFMBOSI.js.map +0 -1
  132. package/dist/chunk-RSZXB6J2.js.map +0 -1
  133. package/dist/components/features/floating-walkthrough-video.d.ts.map +0 -1
  134. /package/dist/{chunk-F3F3PVJ3.js.map → chunk-44U25GD5.js.map} +0 -0
  135. /package/dist/{chunk-IGO6GNG7.js.map → chunk-5AEHCGQV.js.map} +0 -0
  136. /package/dist/{chunk-GMDPQ5FQ.js.map → chunk-B6TM3QAL.js.map} +0 -0
  137. /package/dist/{chunk-WIPZ65KU.js.map → chunk-JCQUWIPT.js.map} +0 -0
  138. /package/dist/{chunk-2IL7SPBP.js.map → chunk-KRYLVEXU.js.map} +0 -0
  139. /package/dist/{chunk-YNRK3N2D.js.map → chunk-KWNVQZ5Y.js.map} +0 -0
  140. /package/dist/{chunk-QXDEXQDI.js.map → chunk-MLSDBNKT.js.map} +0 -0
  141. /package/dist/{chunk-65ZXR4YJ.js.map → chunk-NGY4KET4.js.map} +0 -0
  142. /package/dist/{chunk-QKGNSXF2.js.map → chunk-OCJG3X4U.js.map} +0 -0
  143. /package/dist/{chunk-VAAQZZHT.js.map → chunk-ONDI64M3.js.map} +0 -0
  144. /package/dist/{chunk-BKQWCJP5.js.map → chunk-QGQ26NEE.js.map} +0 -0
  145. /package/dist/{chunk-57G6CG2E.js.map → chunk-YUAKZNK5.js.map} +0 -0
  146. /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 { ChevronLeft, ChevronRight } from 'lucide-react'
5
- import { Calendar } from '../calendar'
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 lib's ODS-styled `Calendar` (react-day-picker v9
14
- * wrapper)NOT a bespoke day grid. `startMonth`/`endMonth` pin navigation
15
- * to [current month, current + MAX_MONTH_OFFSET] so the past is unreachable;
16
- * days without bookable slots are disabled (visually muted via the v9
17
- * `disabled` classNames). Month navigation drives the host-proxy
18
- * `monthOffset`. Time chips are plain `Button`s whose variant flips on
19
- * selection no bespoke pill.
12
+ * Day layer is the design system's own `DatePickerCalendar`, in its `bare`
13
+ * variantthe 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
- * LAZY STATES (no jumps, ever): each region owns a same-footprint skeleton
22
- * `CalendarSkeleton` (caption + weekday row + 6×7 day grid) and
23
- * `TimeChipsSkeleton` (day heading + chip grid). During an availability
24
- * refetch (month change) BOTH regions swap to their skeletons; the region
25
- * containers carry fixed min-heights so loading loaded never shifts layout.
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
- /** Calendar region min-height: caption (32) + gap (≤12) + table with 6 week
75
- * rows (8 outer border-spacing + 7×36 rows + 6×4 row gaps = 284) = 328px.
76
- * Sized to the TALLEST month so 5-row months, 6-row months, skeletons and
77
- * loaded states all occupy identical space — measured live, not estimated. */
78
- const CALENDAR_MIN_H = 'min-h-[20.5rem]'
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
- * Same-footprint skeleton for the day-selection calendar. STATIC chrome
92
- * month caption, BOXED nav chevrons, weekday labels renders REAL (it needs
93
- * no data); only the day buttons are skeleton cells.
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
- * GEOMETRY IS EXACT, not approximate it mirrors the v9 Calendar's real
96
- * classes: caption row h-8 with h-8/w-8 bordered nav boxes; the grid has NO
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
- export function CalendarSkeleton({ monthOffset = 0 }: { monthOffset?: number }) {
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={cn('flex flex-col gap-[var(--spacing-system-s)]', CALENDAR_MIN_H)}>
105
- <div className="relative h-8">
106
- <div className="absolute inset-x-0 top-0 flex h-8 items-center justify-between">
107
- <span className="flex h-8 w-8 items-center justify-center rounded-md border border-ods-border opacity-60">
108
- <ChevronLeft className="h-4 w-4 text-ods-text-muted" />
109
- </span>
110
- <span className="flex h-8 w-8 items-center justify-center rounded-md border border-ods-border opacity-60">
111
- <ChevronRight className="h-4 w-4 text-ods-text-muted" />
112
- </span>
113
- </div>
114
- <div className="flex h-8 items-center justify-center">
115
- <p className="text-h6 text-ods-text-primary">{monthLabelFor(monthOffset)}</p>
116
- </div>
117
- </div>
118
- {/* Cells are NOT fixed-width: the real table stretches to its
119
- container (columns share the extra width, buttons centered) the
120
- skeleton's grid tracks do exactly the same. */}
121
- <div className="flex flex-col gap-y-1 py-1">
122
- <div className="grid grid-cols-7">
123
- {WEEKDAY_LABELS.map((d) => (
124
- <div key={d} className="flex h-9 items-center justify-center text-h6 font-normal text-ods-text-secondary">
125
- {d}
126
- </div>
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 full slot-area skeleton calendar + time-chip regions side by side in
145
- * the SAME flex wrappers as the loaded `SlotPicker`. This is the ONLY
146
- * loading visual for the slot area, used by every path that has nothing to
147
- * show yet (initial availability fetch, the pre-mount timezone-resolution
148
- * window, month-change refetch) never a bare rectangle.
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="flex flex-col md:flex-row gap-[var(--spacing-system-lf)]">
153
- <div className={cn('shrink-0', CALENDAR_MIN_H)}>
154
- <CalendarSkeleton monthOffset={monthOffset} />
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="flex-1 min-w-0 flex flex-col gap-[var(--spacing-system-s)]">
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
- {/* Day-heading line: text-h6 is 16px tall on mobile, 20px from md up. */}
168
- <Skeleton className="h-4 md:h-5 w-40" />
169
- <div className="grid grid-cols-2 sm:grid-cols-3 gap-[var(--spacing-system-xs)] content-start">
170
- {Array.from({ length: 9 }, (_, i) => (
171
- <Skeleton key={i} className="h-10 w-full" />
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 now = new Date()
202
- const startMonth = new Date(now.getFullYear(), now.getMonth(), 1)
203
- const endMonth = new Date(now.getFullYear(), now.getMonth() + MAX_MONTH_OFFSET, 1)
204
- const visibleMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
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="flex flex-col md:flex-row gap-[var(--spacing-system-lf)]">
210
- <div className={cn('shrink-0', CALENDAR_MIN_H)}>
211
- {isLoading ? (
212
- <CalendarSkeleton monthOffset={monthOffset} />
213
- ) : (
214
- <Calendar
215
- month={visibleMonth}
216
- startMonth={startMonth}
217
- endMonth={endMonth}
218
- onMonthChange={(month) => {
219
- const offset = (month.getFullYear() - now.getFullYear()) * 12 + (month.getMonth() - now.getMonth())
220
- onMonthOffsetChange(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)))
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="flex-1 min-w-0 flex flex-col gap-[var(--spacing-system-s)]">
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="text-h6 text-ods-text-primary">
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="grid grid-cols-2 sm:grid-cols-3 gap-[var(--spacing-system-xs)] content-start max-h-80 overflow-y-auto">
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
- <p className="text-h6 text-ods-text-secondary">
271
- {[...slotsByDay.keys()].some((k) =>
272
- k.startsWith(
273
- `${visibleMonth.getFullYear()}-${String(visibleMonth.getMonth() + 1).padStart(2, '0')}`,
274
- ),
275
- )
276
- ? 'Pick a day to see available times.'
277
- : 'No available times this month — try the next one.'}
278
- </p>
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>