@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
|
@@ -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
|
-
|
|
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
|
-
|
|
203
|
-
const
|
|
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(
|
|
207
|
-
|
|
208
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
// Painted through the `todayTint` modifier instead — see `isUnclaimedToday`.
|
|
375
|
+
today: "",
|
|
234
376
|
selected: cn(
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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={
|
|
376
|
-
<div className=
|
|
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=
|
|
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={
|
|
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",
|
|
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=
|
|
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={
|
|
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=
|
|
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 {
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
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
|
|
78
|
+
const queryClient = useQueryClient()
|
|
52
79
|
|
|
53
|
-
const
|
|
54
|
-
const [monthOffset, setMonthOffsetState] = useState(
|
|
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
|
|
65
|
-
// PREVIOUS link's month. Layout effect so the
|
|
66
|
-
//
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
112
|
-
|
|
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)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
[
|
|
167
|
+
[mutateAsync],
|
|
147
168
|
)
|
|
148
169
|
|
|
149
170
|
return {
|
|
150
|
-
availability,
|
|
151
|
-
|
|
152
|
-
|
|
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
|
}
|