@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
@@ -100,12 +100,18 @@ interface CalendarNavButtonProps {
100
100
  "aria-label"?: string;
101
101
  }
102
102
 
103
+ /** The DS icon button, at its standard size in every placement — month
104
+ * navigation is a primary control, not chrome to be shrunk to fit. What has
105
+ * to give instead is the calendar's WIDTH: a `bare` host states one wide
106
+ * enough for the caption to sit between the two buttons (see the meeting
107
+ * scheduler's `CALENDAR_W`). */
103
108
  function CalendarNavButton({ direction, onClick, disabled, "aria-label": ariaLabel }: CalendarNavButtonProps) {
104
109
  return (
105
110
  <Button
106
111
  type="button"
107
112
  variant="outline"
108
113
  size="icon"
114
+ className="shrink-0"
109
115
  onClick={onClick}
110
116
  disabled={disabled}
111
117
  aria-label={ariaLabel}
@@ -118,7 +124,7 @@ function CalendarNavButton({ direction, onClick, disabled, "aria-label": ariaLab
118
124
  // DatePickerCalendar Component
119
125
  // ============================================================================
120
126
 
121
- interface DatePickerCalendarProps {
127
+ export interface DatePickerCalendarProps {
122
128
  mode: DatePickerMode;
123
129
  selected: Date | DateRange | undefined;
124
130
  onSelect: (value: Date | DateRange | undefined) => void;
@@ -132,9 +138,54 @@ interface DatePickerCalendarProps {
132
138
  * lines up with the surrounding controls per the Figma filter-menu.
133
139
  */
134
140
  fluid?: boolean;
141
+ /**
142
+ * The calendar ALONE — the Figma `Date Picker` component as drawn, with no
143
+ * card surface and no outer inset: the nav header flush to both edges, 8px,
144
+ * then a full-bleed grid. Seven columns divide the container, so the
145
+ * cells are as wide as the host makes them.
146
+ *
147
+ * FIXED 272px tall at every one of those widths (8 + the 48px icon-button
148
+ * header + a 24px weekday strip + six 32px rows; 268 on a phone, where the
149
+ * DS icon button is 44), which is the design's own contract — the
150
+ * component is drawn 240 tall at 340, 282 and 240 wide. Height that ignored
151
+ * the width is what lets a host place it in a card that states a height, and
152
+ * keeps a phone-width month from becoming a 356px band.
153
+ *
154
+ * The DAY inside each cell stays square, so the selection and today's tint
155
+ * read as rounded squares rather than as pills stretched across a wide
156
+ * cell.
157
+ *
158
+ * For hosts that place the calendar inside a panel they already own — the
159
+ * meeting scheduler — where the standalone card chrome reads as a card
160
+ * inside a card and its 16px inset fights the panel's own 24px one. The
161
+ * popover and filter-menu keep the surface; nothing about the type, colours
162
+ * or day states changes between the two.
163
+ */
164
+ bare?: boolean;
165
+ /**
166
+ * Days to disable ON TOP of the `fromDate`/`toDate` bounds — a matcher, so a
167
+ * host can disable by predicate. "Inside the allowed window" and "actually
168
+ * selectable" are different questions, and only the host can answer the
169
+ * second (the meeting scheduler greys out days with no bookable slot).
170
+ */
171
+ disabledDays?: Matcher | Matcher[];
172
+ /**
173
+ * Controlled visible month; pair with `onMonthChange`. Omit both and the
174
+ * calendar owns its month, which is the right default — a host needs these
175
+ * only when paging is ALSO a data event, e.g. the scheduler refetches
176
+ * availability per month and would otherwise show an unpopulated month.
177
+ */
178
+ month?: Date;
179
+ onMonthChange?: (month: Date) => void;
135
180
  }
136
181
 
137
- function DatePickerCalendar({
182
+ /**
183
+ * The calendar surface behind every date control in the design system —
184
+ * `DatePicker`'s popover and `DateFilterMenu` — exported so a host that needs
185
+ * an always-visible day grid renders THIS one. A second hand-styled day grid
186
+ * is how the two drift apart.
187
+ */
188
+ export function DatePickerCalendar({
138
189
  mode,
139
190
  selected,
140
191
  onSelect,
@@ -143,6 +194,10 @@ function DatePickerCalendar({
143
194
  toDate,
144
195
  locale,
145
196
  fluid = false,
197
+ bare = false,
198
+ disabledDays: extraDisabledDays,
199
+ month: monthProp,
200
+ onMonthChange,
146
201
  }: DatePickerCalendarProps) {
147
202
  const today = new Date();
148
203
 
@@ -199,19 +254,105 @@ function DatePickerCalendar({
199
254
  };
200
255
 
201
256
  // Fixed 40px cells by default; in fluid mode cells flex to fill the width.
202
- const cellOuter = fluid ? "flex-1 aspect-square min-w-0" : "size-10";
203
- const cellInner = fluid ? "size-full" : "size-10";
257
+ // `bare` is a fluid layout by definition its cell size IS container/7.
258
+ const isFluid = fluid || bare;
259
+ // BARE: fixed row height, elastic width — the Figma component is 240px tall
260
+ // at every width it is drawn at (340 on a phone, 282 on a tablet, 240 on the
261
+ // desktop card), so a month occupies the same band whatever column it lands
262
+ // in. 8 + the 48px header + a 24px weekday strip + six 32px rows = 272.
263
+ //
264
+ // Height that does NOT follow the width is the whole point: seven square
265
+ // cells across a phone's 340px would be a 356px-tall month, half again what
266
+ // the design budgets, and the same rule handed a wide column a month tall
267
+ // enough to push a card that states its height.
268
+ const cellOuter = bare ? "flex-1 min-w-0 h-[32px]" : isFluid ? "flex-1 aspect-square min-w-0" : "size-10";
269
+ // ...but the DAY ITSELF stays square inside that wider cell: `h-full` gives
270
+ // the button the row's height and `aspect-square` turns it into a 32x32
271
+ // rounded square, centred by the cell. `w-auto` is load-bearing — it drops
272
+ // the width the button would otherwise take, leaving `aspect-square` inert.
273
+ //
274
+ // This is why the day STATES below are painted on the button in bare mode
275
+ // rather than on the cell: react-day-picker puts `selected`/`today` on the
276
+ // cell, and a cell 48px wide by 30 tall renders the selection as a stretched
277
+ // pill. Same colours, same radius, smaller box.
278
+ const cellInner = bare
279
+ ? "h-full w-auto aspect-square rounded-[6px]"
280
+ : isFluid
281
+ ? "size-full"
282
+ : "size-10";
283
+ // The weekday strip is a LABEL row, not a day: `bare` gives it the height of
284
+ // its own text rather than a full row.
285
+ const weekdayOuter = bare ? "flex-1 min-w-0 h-6" : cellOuter;
286
+
287
+ // Day states, per paint target (see `cellInner`). Range mode is never bare —
288
+ // a range MUST fill its cells or the middle would break into islands — so
289
+ // only these three single-day states fork.
290
+ const hoverClass = bare ? "[&>button]:hover:bg-ods-bg-surface" : "hover:bg-ods-bg-surface hover:rounded-[6px]";
291
+ const todayClass = bare
292
+ ? "[&>button]:bg-ods-bg-surface [&>button]:hover:!bg-ods-bg-surface"
293
+ : "bg-ods-bg-surface rounded-[6px] hover:!bg-ods-bg-surface";
294
+ /**
295
+ * Today's tint applies only while today is NOT part of the selection.
296
+ *
297
+ * Both states paint the same box, and both have to use `!important` to beat
298
+ * react-day-picker's concatenated base classes — so on hover the two rules
299
+ * had equal specificity and the winner came down to their order in the
300
+ * generated stylesheet. Today's tint won, and a selected today turned GREY
301
+ * under the pointer, as if it had been deselected.
302
+ *
303
+ * Deciding it here rather than in CSS: "today" is a hint about where you
304
+ * are in the month, and once that day is chosen the selection is the
305
+ * stronger statement — there is nothing left for the tint to say.
306
+ */
307
+ const dayNumber = (date: Date): number => date.getFullYear() * 10000 + date.getMonth() * 100 + date.getDate();
308
+ const isSelectedDay = (date: Date): boolean => {
309
+ const n = dayNumber(date);
310
+ if (mode === "single") {
311
+ const picked = selected as Date | undefined;
312
+ return !!picked && dayNumber(picked) === n;
313
+ }
314
+ const range = rangeSelected;
315
+ if (!range?.from) return false;
316
+ if (!range.to) return dayNumber(range.from) === n;
317
+ return n >= dayNumber(range.from) && n <= dayNumber(range.to);
318
+ };
319
+ const isUnclaimedToday = (date: Date): boolean => dayNumber(date) === dayNumber(today) && !isSelectedDay(date);
320
+
321
+ const selectedClass = bare
322
+ ? "[&>button]:!bg-ods-accent [&>button]:!text-ods-card [&>button]:!font-bold [&>button]:hover:!bg-ods-accent"
323
+ : "!bg-ods-accent !text-ods-card !font-bold !rounded-[6px] hover:!bg-ods-accent";
324
+
325
+ // Surface + inset, per placement. Bare drops the card and the 16px inset
326
+ // (the host's panel provides both) and replaces the inset with the 8px the
327
+ // design puts between the header and the grid — the ONLY spacing it keeps.
328
+ const surfaceClass = bare
329
+ ? "flex w-full flex-col"
330
+ : cn("bg-ods-card border border-ods-border rounded-[6px] overflow-hidden", isFluid && "w-full");
331
+ // Bare: an unpadded header row over the grid, 8px apart — the calendar's
332
+ // whole height is the two of them added up, never the container's.
333
+ const captionRowClass = cn("flex items-center justify-between gap-1", bare ? "shrink-0" : "px-4 pt-4");
334
+ // The caption is the only elastic thing in the header: it takes the space
335
+ // the two fixed buttons leave and truncates rather than pushing into them.
336
+ // "September 2026" is ~131px at text-h4, and the two 48px buttons plus their
337
+ // gaps take 104 — so a `bare` host has to state a width of ~256 or more, or
338
+ // the label and the next-month button collide (worse in a locale with longer
339
+ // month names).
340
+ const captionClass = "min-w-0 flex-1 truncate text-center text-h4 text-ods-text-primary";
204
341
 
205
342
  const classNames: DayPickerProps["classNames"] = {
206
- root: cn("p-4 date-picker-calendar", fluid && "w-full"),
207
- months: "flex gap-8",
208
- month: cn("flex flex-col gap-2", fluid && "w-full"),
343
+ root: cn(
344
+ "date-picker-calendar",
345
+ bare ? "flex w-full flex-col pt-[var(--spacing-system-xsf)]" : cn("p-4", isFluid && "w-full"),
346
+ ),
347
+ months: cn("flex gap-8", bare && "w-full"),
348
+ month: cn("flex flex-col", bare ? "w-full" : cn("gap-2", isFluid && "w-full")),
209
349
  month_caption: "hidden",
210
350
  nav: "hidden",
211
- month_grid: cn("border-collapse", fluid && "w-full"),
351
+ month_grid: cn("border-collapse", bare ? "flex w-full flex-col" : isFluid && "w-full"),
212
352
  weekdays: "flex",
353
+ ...(bare ? { weeks: "flex flex-col" } : {}),
213
354
  weekday: cn(
214
- cellOuter,
355
+ weekdayOuter,
215
356
  "flex items-center justify-center",
216
357
  "text-h6 text-ods-text-secondary"
217
358
  ),
@@ -222,7 +363,7 @@ function DatePickerCalendar({
222
363
  "text-h4 text-ods-text-primary",
223
364
  "cursor-pointer",
224
365
  "transition-colors duration-150",
225
- "hover:bg-ods-bg-surface hover:rounded-[6px]"
366
+ hoverClass
226
367
  ),
227
368
  day_button: cn(
228
369
  cellInner,
@@ -230,9 +371,10 @@ function DatePickerCalendar({
230
371
  "cursor-pointer bg-transparent border-none outline-none",
231
372
  "text-inherit font-inherit"
232
373
  ),
233
- today: "bg-ods-bg-surface rounded-[6px] hover:!bg-ods-bg-surface",
374
+ // Painted through the `todayTint` modifier instead — see `isUnclaimedToday`.
375
+ today: "",
234
376
  selected: cn(
235
- "!bg-ods-accent !text-ods-card !font-bold !rounded-[6px] hover:!bg-ods-accent",
377
+ selectedClass,
236
378
  // In range mode, selected class should not override range_start/range_end/range_middle
237
379
  mode === "range" && "range-selected"
238
380
  ),
@@ -250,7 +392,19 @@ function DatePickerCalendar({
250
392
  // `disabled` <button> does not inherit the cell's cursor on its own.
251
393
  disabled: cn(
252
394
  "!text-ods-text-disabled !cursor-not-allowed hover:!bg-transparent",
253
- "[&>button]:!cursor-not-allowed [&>button]:!text-ods-text-disabled"
395
+ "[&>button]:!cursor-not-allowed [&>button]:!text-ods-text-disabled",
396
+ // Also drop the `today` tint, or an unselectable today renders BLANK:
397
+ // `--color-text-disabled` and `--color-bg-surface` are the SAME token
398
+ // (`--ods-system-greys-soft-grey`), so the number disappears into the
399
+ // highlight — grey on identical grey. Reachable by any picker whose
400
+ // `fromDate` is in the future, and permanently by the meeting scheduler,
401
+ // where today usually has no bookable slots left.
402
+ //
403
+ // Dropping the tint rather than repainting the text: a disabled day is
404
+ // not actionable, so a highlight claiming otherwise is the wrong signal.
405
+ // The day still reads as today's position in the month by being the one
406
+ // dimmed cell between yesterday and tomorrow.
407
+ "!bg-transparent [&>button]:!bg-transparent"
254
408
  ),
255
409
  hidden: "invisible",
256
410
  // Range styles matching Figma design:
@@ -269,11 +423,19 @@ function DatePickerCalendar({
269
423
  range_middle: "range-middle !bg-ods-open-yellow-light !text-ods-card !font-medium !rounded-none hover:!bg-ods-open-yellow-light",
270
424
  };
271
425
 
272
- const [month, setMonth] = React.useState<Date>(
426
+ // Controlled when the host passes `month`, self-owned otherwise. The
427
+ // internal state is kept either way so an uncontrolled calendar still works;
428
+ // controlled it is simply never read, which keeps `changeMonth` one path.
429
+ const [uncontrolledMonth, setUncontrolledMonth] = React.useState<Date>(
273
430
  mode === "single"
274
431
  ? (selected as Date) || today
275
432
  : (selected as DateRange)?.from || today
276
433
  );
434
+ const month = monthProp ?? uncontrolledMonth;
435
+ const changeMonth = (next: Date): void => {
436
+ setUncontrolledMonth(next);
437
+ onMonthChange?.(next);
438
+ };
277
439
 
278
440
  /**
279
441
  * `fromDate` / `toDate` → what react-day-picker **v9** actually understands.
@@ -294,8 +456,11 @@ function DatePickerCalendar({
294
456
  const matchers: Matcher[] = [];
295
457
  if (fromDate) matchers.push({ before: fromDate });
296
458
  if (toDate) matchers.push({ after: toDate });
459
+ if (extraDisabledDays) {
460
+ matchers.push(...(Array.isArray(extraDisabledDays) ? extraDisabledDays : [extraDisabledDays]));
461
+ }
297
462
  return matchers;
298
- }, [fromDate, toDate]);
463
+ }, [fromDate, toDate, extraDisabledDays]);
299
464
 
300
465
  // Out-of-bounds days are disabled, not hidden — a month keeps its shape, and
301
466
  // the greyed-out day is what tells the user the bound exists. Navigation stops
@@ -311,21 +476,13 @@ function DatePickerCalendar({
311
476
  const canGoPrevious = !fromDate || monthIndex(month) > monthIndex(fromDate);
312
477
  const canGoNext = !toDate || lastVisibleMonth < monthIndex(toDate);
313
478
 
314
- const handlePreviousMonth = () => {
315
- setMonth((prev) => {
316
- const newMonth = new Date(prev);
317
- newMonth.setMonth(newMonth.getMonth() - 1);
318
- return newMonth;
319
- });
320
- };
321
-
322
- const handleNextMonth = () => {
323
- setMonth((prev) => {
324
- const newMonth = new Date(prev);
325
- newMonth.setMonth(newMonth.getMonth() + 1);
326
- return newMonth;
327
- });
479
+ const shiftMonth = (by: number) => {
480
+ const next = new Date(month);
481
+ next.setMonth(next.getMonth() + by);
482
+ changeMonth(next);
328
483
  };
484
+ const handlePreviousMonth = () => shiftMonth(-1);
485
+ const handleNextMonth = () => shiftMonth(1);
329
486
 
330
487
  const formatMonthYear = (date: Date): string => {
331
488
  return date.toLocaleDateString("en-US", {
@@ -372,17 +529,15 @@ function DatePickerCalendar({
372
529
 
373
530
  if (mode === "single") {
374
531
  return (
375
- <div className={cn("bg-ods-card border border-ods-border rounded-[6px] overflow-hidden", fluid && "w-full")}>
376
- <div className="flex items-center justify-between px-4 pt-4">
532
+ <div className={surfaceClass}>
533
+ <div className={captionRowClass}>
377
534
  <CalendarNavButton
378
535
  direction="left"
379
536
  onClick={handlePreviousMonth}
380
537
  disabled={!canGoPrevious}
381
538
  aria-label="Previous month"
382
539
  />
383
- <span className="text-h4 text-ods-text-primary">
384
- {formatMonthYear(month)}
385
- </span>
540
+ <span className={captionClass}>{formatMonthYear(month)}</span>
386
541
  <CalendarNavButton
387
542
  direction="right"
388
543
  onClick={handleNextMonth}
@@ -395,8 +550,10 @@ function DatePickerCalendar({
395
550
  selected={selected as Date | undefined}
396
551
  onSelect={(date) => onSelect(date)}
397
552
  month={month}
398
- onMonthChange={setMonth}
553
+ onMonthChange={changeMonth}
399
554
  classNames={classNames}
555
+ modifiers={{ todayTint: isUnclaimedToday }}
556
+ modifiersClassNames={{ todayTint: todayClass }}
400
557
  showOutsideDays
401
558
  fixedWeeks
402
559
  disabled={disabledDays}
@@ -410,7 +567,7 @@ function DatePickerCalendar({
410
567
  // Range mode
411
568
  return (
412
569
  <div
413
- className={cn("bg-ods-card border border-ods-border rounded-md overflow-hidden", fluid && "w-full")}
570
+ className={cn("bg-ods-card border border-ods-border rounded-md overflow-hidden", isFluid && "w-full")}
414
571
  onMouseLeave={() => setHoveredDate(undefined)}
415
572
  >
416
573
  <style>{rangeStyles}</style>
@@ -424,9 +581,7 @@ function DatePickerCalendar({
424
581
  disabled={!canGoPrevious}
425
582
  aria-label="Previous month"
426
583
  />
427
- <span className="text-h4 text-ods-text-primary">
428
- {formatMonthYear(month)}
429
- </span>
584
+ <span className={captionClass}>{formatMonthYear(month)}</span>
430
585
  {monthsToShow === 1 && (
431
586
  <CalendarNavButton
432
587
  direction="right"
@@ -442,10 +597,10 @@ function DatePickerCalendar({
442
597
  selected={draftRange}
443
598
  onSelect={(_range, triggerDate) => handleRangeSelect(triggerDate)}
444
599
  onDayMouseEnter={(day) => setHoveredDate(day)}
445
- modifiers={{ preview: isPreviewDate }}
446
- modifiersClassNames={{ preview: "bg-ods-bg-surface" }}
600
+ modifiers={{ preview: isPreviewDate, todayTint: isUnclaimedToday }}
601
+ modifiersClassNames={{ preview: "bg-ods-bg-surface", todayTint: todayClass }}
447
602
  month={month}
448
- onMonthChange={setMonth}
603
+ onMonthChange={changeMonth}
449
604
  classNames={classNames}
450
605
  showOutsideDays
451
606
  fixedWeeks
@@ -460,9 +615,7 @@ function DatePickerCalendar({
460
615
  <div className="flex-1 border-l border-ods-border">
461
616
  <div className="flex items-center justify-between px-4 pt-4">
462
617
  <div className="size-10 md:size-12" />
463
- <span className="text-h4 text-ods-text-primary">
464
- {formatMonthYear(getSecondMonth(month))}
465
- </span>
618
+ <span className={captionClass}>{formatMonthYear(getSecondMonth(month))}</span>
466
619
  <CalendarNavButton
467
620
  direction="right"
468
621
  onClick={handleNextMonth}
@@ -475,8 +628,8 @@ function DatePickerCalendar({
475
628
  selected={draftRange}
476
629
  onSelect={(_range, triggerDate) => handleRangeSelect(triggerDate)}
477
630
  onDayMouseEnter={(day) => setHoveredDate(day)}
478
- modifiers={{ preview: isPreviewDate }}
479
- modifiersClassNames={{ preview: "bg-ods-bg-surface" }}
631
+ modifiers={{ preview: isPreviewDate, todayTint: isUnclaimedToday }}
632
+ modifiersClassNames={{ preview: "bg-ods-bg-surface", todayTint: todayClass }}
480
633
  month={getSecondMonth(month)}
481
634
  classNames={classNames}
482
635
  showOutsideDays
@@ -7,6 +7,13 @@ import { cn } from "../../utils/cn"
7
7
  export interface FieldWrapperProps {
8
8
  /** Label text displayed above the field */
9
9
  label?: string
10
+ /**
11
+ * `id` of the control the label names. Without it the label is decoration:
12
+ * clicking it focuses nothing and assistive tech reads the field unnamed,
13
+ * which is why every caller that cared was rendering its own <Label> instead
14
+ * of using `label` here.
15
+ */
16
+ htmlFor?: string
10
17
  /**
11
18
  * Status message displayed below the field, on ONE line, ellipsized on
12
19
  * overflow (full text via the native `title`).
@@ -33,7 +40,7 @@ const errorVariantClasses = {
33
40
  } as const
34
41
 
35
42
  const FieldWrapper = React.forwardRef<HTMLDivElement, FieldWrapperProps>(
36
- ({ label, error, errorVariant = "error", className, children }, ref) => {
43
+ ({ label, htmlFor, error, errorVariant = "error", className, children }, ref) => {
37
44
  const hasChrome = label != null || error != null
38
45
 
39
46
  return (
@@ -44,7 +51,7 @@ const FieldWrapper = React.forwardRef<HTMLDivElement, FieldWrapperProps>(
44
51
  // Field-wrapped controls with `label=`-prop controls showed two label
45
52
  // sizes side by side — the exact inconsistency the admin editors kept
46
53
  // reporting. One label scale, owned here and in Field together.
47
- <Label className="mb-1">
54
+ <Label className="mb-1" htmlFor={htmlFor}>
48
55
  {label}
49
56
  </Label>
50
57
  )}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react'
4
5
  import { contentFetch } from '../utils/embed-content-fetch'
5
6
  import { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention'
6
7
  import type {
@@ -13,14 +14,31 @@ import type {
13
14
  * useMeetingBooking — data layer for `<HubSpotMeetingScheduler>` (the widget
14
15
  * files never inline fetch; same split as `useContactSubmission`/ContactForm).
15
16
  *
16
- * Availability: plain `useState` + `useEffect` + `AbortController` self-fetch
17
- * (NO client cache layer). An `initialAvailability` seed (SSR mode) renders
18
- * immediately, but the hook REFETCHES UNCONDITIONALLY after mount the seed
19
- * is first-paint scaffolding for a 60-second-volatile resource, not truth.
17
+ * REQUIRES a `QueryClientProvider` in the host, like every other query-backed
18
+ * surface in this lib (tickets, chat, onboarding-guides). `@tanstack/react-query`
19
+ * is a declared peer dependency; the lib never mounts a provider of its own.
20
20
  *
21
- * Booking: single POST guarded by `isSubmitting` (the only double-booking
22
- * guard the transport never retries POST). Errors surface as typed
23
- * `MeetingBookingErrorCode`s the widget keys its recovery UI off.
21
+ * Availability is a per-month GET, cached per (host, link, month). The cache is
22
+ * the point: paging back to a month already seen is INSTANT and silent, which
23
+ * is what a hand-rolled `useEffect` + `useState` could not do it re-fetched
24
+ * and re-skeletoned the same month every time the visitor stepped back.
25
+ *
26
+ * Deliberately NOT `placeholderData: keepPreviousData`. The payload is
27
+ * month-scoped, so last month's slots under this month's caption describe days
28
+ * that aren't on screen — a grid that lights up the wrong dates is worse than
29
+ * one that is briefly inert. What kills the flicker instead is WHERE the
30
+ * loading state lands: the calendar is fully derivable from the date alone, so
31
+ * it keeps rendering (with nothing selectable yet) while only the times column
32
+ * — the part that genuinely has no answer — shows a placeholder.
33
+ *
34
+ * `staleTime` is deliberately short: HubSpot slots go stale in about a minute,
35
+ * and a slot that is gone by the time it is clicked costs a booking. Fresh
36
+ * enough to trust, cached enough not to blink.
37
+ *
38
+ * Booking: a mutation, guarded so a second submit can't start while one is in
39
+ * flight (the transport never retries POST — this is the only double-booking
40
+ * guard). Errors surface as typed `MeetingBookingErrorCode`s the widget keys
41
+ * its recovery UI off, never as thrown rejections.
24
42
  *
25
43
  * All requests go through the host proxy (`apiBaseUrl` prefix, FaqSection
26
44
  * precedent) via `contentFetch`, so embedded hosts with an embed-auth adapter
@@ -42,28 +60,33 @@ const KNOWN_CODES: MeetingBookingErrorCode[] = [
42
60
  'MEETING_UNAVAILABLE',
43
61
  ]
44
62
 
63
+ /** ~half HubSpot's own slot volatility window — see the docblock. */
64
+ const AVAILABILITY_STALE_MS = 30_000
65
+
66
+ /** Query key for one month of one link. Exported so a host can prefetch or
67
+ * invalidate a month it knows changed (e.g. after booking elsewhere). */
68
+ export function meetingAvailabilityKey(apiBaseUrl: string, meetingId: string, monthOffset: number) {
69
+ return ['meeting-availability', apiBaseUrl, meetingId, monthOffset] as const
70
+ }
71
+
45
72
  export function useMeetingBooking(options: {
46
73
  meetingId: string
47
74
  apiBaseUrl?: string
48
75
  initialAvailability?: MeetingAvailability
49
76
  }) {
50
77
  const { meetingId, apiBaseUrl = '', initialAvailability } = options
51
- const seededFor = initialAvailability?.meetingId === meetingId ? initialAvailability : undefined
78
+ const queryClient = useQueryClient()
52
79
 
53
- const [availability, setAvailability] = useState<MeetingAvailability | null>(seededFor ?? null)
54
- const [monthOffset, setMonthOffsetState] = useState(seededFor?.monthOffset ?? 0)
55
- const [isLoadingAvailability, setIsLoadingAvailability] = useState(!seededFor)
56
- const [availabilityError, setAvailabilityError] = useState<string | null>(null)
57
- const [isSubmitting, setIsSubmitting] = useState(false)
58
- const submittingRef = useRef(false)
80
+ const seededOffset = initialAvailability?.meetingId === meetingId ? initialAvailability.monthOffset : undefined
81
+ const [monthOffset, setMonthOffsetState] = useState(seededOffset ?? 0)
59
82
 
60
83
  const setMonthOffset = useCallback((offset: number) => {
61
84
  setMonthOffsetState(Math.max(0, Math.min(MAX_MONTH_OFFSET, offset)))
62
85
  }, [])
63
86
 
64
- // Link switch resets paging — without this, the new link is fetched at the
65
- // PREVIOUS link's month. Layout effect so the fetch effect below sees the
66
- // reset offset in the same commit.
87
+ // Link switch resets paging — without this, the new link is queried at the
88
+ // PREVIOUS link's month. Layout effect so the query below sees the reset
89
+ // offset in the same commit.
67
90
  const prevMeetingIdRef = useRef(meetingId)
68
91
  useLayoutEffect(() => {
69
92
  if (prevMeetingIdRef.current === meetingId) return
@@ -71,58 +94,40 @@ export function useMeetingBooking(options: {
71
94
  setMonthOffsetState(0)
72
95
  }, [meetingId])
73
96
 
74
- // Unconditional (re)fetch on mount and on (meetingId, monthOffset) change
75
- // even when SSR-seeded (the seed may already be stale).
76
- useEffect(() => {
77
- const controller = new AbortController()
78
- let active = true
79
- const load = async () => {
80
- // Same-month refresh (the unconditional post-mount refetch of an SSR
81
- // seed) keeps the grid on screen; a DIFFERENT month or link has nothing
82
- // truthful to show — loading turns on so the widget renders its
83
- // per-region skeletons instead of a stale grid under a new caption.
84
- setAvailabilityError(null)
85
- if (!availability || availability.meetingId !== meetingId || availability.monthOffset !== monthOffset) {
86
- setIsLoadingAvailability(true)
87
- }
88
- try {
89
- const params = new URLSearchParams({ meeting: meetingId, monthOffset: String(monthOffset) })
90
- const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`, {
91
- signal: controller.signal,
92
- })
93
- if (!res.ok) throw new Error(`availability ${res.status}`)
94
- const data = (await res.json()) as MeetingAvailability
95
- if (active) setAvailability(data)
96
- } catch (err) {
97
- if (!active || (err instanceof DOMException && err.name === 'AbortError')) return
98
- setAvailabilityError(err instanceof Error ? err.message : 'Failed to load availability')
99
- } finally {
100
- if (active) setIsLoadingAvailability(false)
101
- }
102
- }
103
- void load()
104
- return () => {
105
- active = false
106
- controller.abort()
107
- }
108
- // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch is keyed on (meetingId, monthOffset) only; `availability` is read for skeleton gating, not as a trigger
109
- }, [meetingId, monthOffset, apiBaseUrl])
97
+ // The SSR seed covers exactly one (link, month) cell of the cache.
98
+ const seed =
99
+ initialAvailability?.meetingId === meetingId && initialAvailability.monthOffset === monthOffset
100
+ ? initialAvailability
101
+ : undefined
110
102
 
111
- const refetchAvailability = useCallback(async () => {
112
- try {
103
+ const availabilityQuery = useQuery({
104
+ queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset),
105
+ queryFn: async ({ signal }) => {
113
106
  const params = new URLSearchParams({ meeting: meetingId, monthOffset: String(monthOffset) })
114
- const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`)
115
- if (res.ok) setAvailability((await res.json()) as MeetingAvailability)
116
- } catch {
117
- /* recovery refetch is best-effort — the stale grid stays visible */
118
- }
119
- }, [meetingId, monthOffset, apiBaseUrl])
107
+ const res = await contentFetch(`${apiBaseUrl}/api/meetings/availability?${params}`, { signal })
108
+ if (!res.ok) throw new Error(`availability ${res.status}`)
109
+ return (await res.json()) as MeetingAvailability
110
+ },
111
+ staleTime: AVAILABILITY_STALE_MS,
112
+ initialData: seed,
113
+ // Stamped as infinitely old on purpose: the seed is first-paint
114
+ // scaffolding for a 60-second-volatile resource, not truth, so it paints
115
+ // immediately AND is refetched immediately.
116
+ initialDataUpdatedAt: seed ? 0 : undefined,
117
+ })
120
118
 
121
- const book = useCallback(
122
- async (payload: Record<string, unknown>): Promise<BookingResult> => {
123
- if (submittingRef.current) return { ok: false, code: 'VALIDATION', message: 'Already submitting' }
124
- submittingRef.current = true
125
- setIsSubmitting(true)
119
+ const refetchAvailability = useCallback(async () => {
120
+ // Recovery path (a slot taken from under the visitor): drop the cached
121
+ // month so the grid reloads from the network, not from the copy that
122
+ // still lists the taken slot. Best-effort — a failure leaves the stale
123
+ // grid up rather than blanking the card.
124
+ await queryClient
125
+ .invalidateQueries({ queryKey: meetingAvailabilityKey(apiBaseUrl, meetingId, monthOffset) })
126
+ .catch(() => undefined)
127
+ }, [queryClient, apiBaseUrl, meetingId, monthOffset])
128
+
129
+ const bookMutation = useMutation<BookingResult, never, Record<string, unknown>>({
130
+ mutationFn: async (payload) => {
126
131
  try {
127
132
  const res = await contentFetch(`${apiBaseUrl}/api/meetings/book`, {
128
133
  method: 'POST',
@@ -138,22 +143,41 @@ export function useMeetingBooking(options: {
138
143
  return { ok: false, code, message: typeof data.error === 'string' ? data.error : undefined }
139
144
  } catch {
140
145
  return { ok: false, code: 'TEMPORARILY_UNAVAILABLE' }
146
+ }
147
+ },
148
+ // A booking is not a read — never retried, at any layer.
149
+ retry: false,
150
+ })
151
+
152
+ // `isPending` alone would not stop a second submit dispatched in the same
153
+ // tick as the first; the ref closes that window the way the pre-query
154
+ // implementation did.
155
+ const submittingRef = useRef(false)
156
+ const { mutateAsync } = bookMutation
157
+ const book = useCallback(
158
+ async (payload: Record<string, unknown>): Promise<BookingResult> => {
159
+ if (submittingRef.current) return { ok: false, code: 'VALIDATION', message: 'Already submitting' }
160
+ submittingRef.current = true
161
+ try {
162
+ return await mutateAsync(payload)
141
163
  } finally {
142
164
  submittingRef.current = false
143
- setIsSubmitting(false)
144
165
  }
145
166
  },
146
- [apiBaseUrl],
167
+ [mutateAsync],
147
168
  )
148
169
 
149
170
  return {
150
- availability,
151
- isLoadingAvailability,
152
- availabilityError,
171
+ availability: availabilityQuery.data ?? null,
172
+ /** No availability for this month yet — the times column has nothing to show. */
173
+ isLoadingAvailability: availabilityQuery.isPending,
174
+ /** ANY request in flight, including a silent revalidation of cached data. */
175
+ isFetchingAvailability: availabilityQuery.isFetching,
176
+ availabilityError: availabilityQuery.error ? availabilityQuery.error.message : null,
153
177
  monthOffset,
154
178
  setMonthOffset,
155
179
  refetchAvailability,
156
180
  book,
157
- isSubmitting,
181
+ isSubmitting: bookMutation.isPending,
158
182
  } as const
159
183
  }