@asksable/site-connector 0.1.1 → 0.1.4
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/booking-widget.d.ts +6 -2
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +436 -104
- package/dist/booking-widget.js.map +1 -1
- package/dist/styles.css +1188 -0
- package/package.json +9 -3
package/dist/booking-widget.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useSableSiteClient, useSableSiteConfig } from './provider.js';
|
|
4
|
-
|
|
4
|
+
const MOBILE_PROGRESS_STEPS = [1, 2, 3, 4];
|
|
5
|
+
export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
5
6
|
const client = useSableSiteClient();
|
|
6
7
|
const { siteSlug } = useSableSiteConfig();
|
|
7
8
|
const [setup, setSetup] = useState(null);
|
|
8
9
|
const [isSetupLoading, setIsSetupLoading] = useState(true);
|
|
9
10
|
const [selectedServiceId, setSelectedServiceId] = useState(null);
|
|
10
11
|
const [selectedStaffId, setSelectedStaffId] = useState(null);
|
|
11
|
-
const [
|
|
12
|
-
const [
|
|
12
|
+
const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
|
|
13
|
+
const [availabilityByDate, setAvailabilityByDate] = useState(new Map());
|
|
14
|
+
const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
|
|
15
|
+
const [selectedDate, setSelectedDate] = useState(null);
|
|
13
16
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
17
|
+
const [pendingSlotKey, setPendingSlotKey] = useState(null);
|
|
14
18
|
const [holdId, setHoldId] = useState(null);
|
|
15
19
|
const [holdExpiresAt, setHoldExpiresAt] = useState(null);
|
|
16
20
|
const [heldStaffId, setHeldStaffId] = useState(null);
|
|
@@ -19,10 +23,15 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
19
23
|
const [customerPhone, setCustomerPhone] = useState('');
|
|
20
24
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
21
25
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
22
|
-
const [isSlotsLoading, setIsSlotsLoading] = useState(false);
|
|
23
26
|
const [error, setError] = useState(null);
|
|
24
27
|
const [success, setSuccess] = useState(null);
|
|
28
|
+
const [mobileStep, setMobileStep] = useState(1);
|
|
29
|
+
const [monthOpen, setMonthOpen] = useState(false);
|
|
30
|
+
const [staffOpen, setStaffOpen] = useState(false);
|
|
31
|
+
const [holdNow, setHoldNow] = useState(() => Date.now());
|
|
25
32
|
const [sessionToken] = useState(() => sessionTokenFromBrowser());
|
|
33
|
+
const staffMenuRef = useRef(null);
|
|
34
|
+
const availabilityCacheRef = useRef(new Map());
|
|
26
35
|
useEffect(() => {
|
|
27
36
|
let cancelled = false;
|
|
28
37
|
setIsSetupLoading(true);
|
|
@@ -34,6 +43,7 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
34
43
|
}
|
|
35
44
|
setSetup(result);
|
|
36
45
|
setSelectedServiceId((current) => current || result.services[0]?._id || null);
|
|
46
|
+
setError(null);
|
|
37
47
|
})
|
|
38
48
|
.catch((nextError) => {
|
|
39
49
|
if (!cancelled) {
|
|
@@ -49,6 +59,19 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
49
59
|
cancelled = true;
|
|
50
60
|
};
|
|
51
61
|
}, [client, siteSlug]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
function handleDocumentClick(event) {
|
|
64
|
+
if (!staffMenuRef.current?.contains(event.target)) {
|
|
65
|
+
setStaffOpen(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (staffOpen) {
|
|
69
|
+
document.addEventListener('mousedown', handleDocumentClick);
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
document.removeEventListener('mousedown', handleDocumentClick);
|
|
73
|
+
};
|
|
74
|
+
}, [staffOpen]);
|
|
52
75
|
const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
|
|
53
76
|
const availableStaff = useMemo(() => {
|
|
54
77
|
if (!setup || !selectedService) {
|
|
@@ -57,50 +80,98 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
57
80
|
return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
|
|
58
81
|
}, [selectedService, setup]);
|
|
59
82
|
useEffect(() => {
|
|
60
|
-
if (!selectedStaffId) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (!availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
|
|
83
|
+
if (selectedStaffId && !availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
|
|
64
84
|
setSelectedStaffId(null);
|
|
65
85
|
}
|
|
66
86
|
}, [availableStaff, selectedStaffId]);
|
|
67
87
|
const selectedStaff = useMemo(() => availableStaff.find((staffMember) => staffMember._id === selectedStaffId) ?? null, [availableStaff, selectedStaffId]);
|
|
68
|
-
const nextDateOptions = useMemo(() => Array.from({ length: 6 }, (_, index) => dateKeyFromOffset(index)), []);
|
|
69
88
|
useEffect(() => {
|
|
70
89
|
if (!selectedServiceId) {
|
|
71
|
-
|
|
90
|
+
setAvailabilityByDate(new Map());
|
|
91
|
+
setSelectedDate(null);
|
|
72
92
|
return;
|
|
73
93
|
}
|
|
74
94
|
let cancelled = false;
|
|
75
|
-
|
|
95
|
+
const monthStart = formatDateKey(calendarMonth);
|
|
96
|
+
const monthEnd = formatDateKey(endOfMonth(calendarMonth));
|
|
97
|
+
const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
|
|
98
|
+
const cachedAvailability = availabilityCacheRef.current.get(requestKey);
|
|
99
|
+
if (cachedAvailability) {
|
|
100
|
+
setAvailabilityByDate(cachedAvailability);
|
|
101
|
+
setIsAvailabilityLoading(false);
|
|
102
|
+
const currentSelectionIsVisible = selectedDate &&
|
|
103
|
+
isDateInMonth(selectedDate, calendarMonth) &&
|
|
104
|
+
(cachedAvailability.get(selectedDate)?.length ?? 0) > 0;
|
|
105
|
+
if (!currentSelectionIsVisible) {
|
|
106
|
+
setSelectedDate(findFirstAvailableDate(cachedAvailability, calendarMonth));
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setIsAvailabilityLoading(true);
|
|
76
111
|
void client
|
|
77
112
|
.getPublicAvailableSlots({
|
|
78
113
|
siteSlug,
|
|
79
114
|
serviceId: selectedServiceId,
|
|
80
115
|
staffMemberId: selectedStaffId ?? undefined,
|
|
81
|
-
startDate:
|
|
82
|
-
endDate:
|
|
116
|
+
startDate: monthStart,
|
|
117
|
+
endDate: monthEnd,
|
|
83
118
|
})
|
|
84
119
|
.then((result) => {
|
|
85
|
-
if (
|
|
86
|
-
|
|
120
|
+
if (cancelled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const nextMap = new Map();
|
|
124
|
+
for (const day of result.dates) {
|
|
125
|
+
nextMap.set(day.date, day.slots.filter((slot) => (slot.availableStaffIds?.length ?? 0) > 0));
|
|
126
|
+
}
|
|
127
|
+
availabilityCacheRef.current.set(requestKey, nextMap);
|
|
128
|
+
setAvailabilityByDate(nextMap);
|
|
129
|
+
setError(null);
|
|
130
|
+
const currentSelectionIsVisible = selectedDate &&
|
|
131
|
+
isDateInMonth(selectedDate, calendarMonth) &&
|
|
132
|
+
(nextMap.get(selectedDate)?.length ?? 0) > 0;
|
|
133
|
+
if (!currentSelectionIsVisible) {
|
|
134
|
+
const firstAvailableDate = findFirstAvailableDate(nextMap, calendarMonth);
|
|
135
|
+
setSelectedDate(firstAvailableDate);
|
|
87
136
|
}
|
|
88
137
|
})
|
|
89
138
|
.catch((nextError) => {
|
|
90
139
|
if (!cancelled) {
|
|
91
|
-
|
|
92
|
-
|
|
140
|
+
setAvailabilityByDate(new Map());
|
|
141
|
+
setSelectedDate(null);
|
|
142
|
+
setError(nextError instanceof Error ? nextError.message : 'Unable to load booking availability.');
|
|
93
143
|
}
|
|
94
144
|
})
|
|
95
145
|
.finally(() => {
|
|
96
146
|
if (!cancelled) {
|
|
97
|
-
|
|
147
|
+
setIsAvailabilityLoading(false);
|
|
98
148
|
}
|
|
99
149
|
});
|
|
100
150
|
return () => {
|
|
101
151
|
cancelled = true;
|
|
102
152
|
};
|
|
103
|
-
}, [client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
|
|
153
|
+
}, [calendarMonth, client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!selectedServiceId) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const nextMonth = addMonths(calendarMonth, 1);
|
|
159
|
+
void prefetchAvailability({
|
|
160
|
+
client,
|
|
161
|
+
siteSlug,
|
|
162
|
+
selectedServiceId,
|
|
163
|
+
selectedStaffId,
|
|
164
|
+
calendarMonth: nextMonth,
|
|
165
|
+
cacheRef: availabilityCacheRef,
|
|
166
|
+
});
|
|
167
|
+
}, [calendarMonth, client, selectedServiceId, selectedStaffId, siteSlug]);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
setSelectedSlot(null);
|
|
170
|
+
setPendingSlotKey(null);
|
|
171
|
+
setHeldStaffId(null);
|
|
172
|
+
setSuccess(null);
|
|
173
|
+
setError(null);
|
|
174
|
+
}, [selectedDate, selectedServiceId, selectedStaffId]);
|
|
104
175
|
useEffect(() => {
|
|
105
176
|
return () => {
|
|
106
177
|
if (!holdId) {
|
|
@@ -115,12 +186,6 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
115
186
|
.catch(() => undefined);
|
|
116
187
|
};
|
|
117
188
|
}, [client, holdId, sessionToken, siteSlug]);
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
setSelectedSlot(null);
|
|
120
|
-
setHeldStaffId(null);
|
|
121
|
-
setSuccess(null);
|
|
122
|
-
setError(null);
|
|
123
|
-
}, [selectedDate, selectedServiceId, selectedStaffId]);
|
|
124
189
|
useEffect(() => {
|
|
125
190
|
if (!holdId || !holdExpiresAt) {
|
|
126
191
|
return;
|
|
@@ -150,57 +215,86 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
150
215
|
}, refreshAt);
|
|
151
216
|
return () => window.clearTimeout(handle);
|
|
152
217
|
}, [client, holdExpiresAt, holdId, sessionToken, siteSlug]);
|
|
153
|
-
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (!holdExpiresAt) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
setHoldNow(Date.now());
|
|
223
|
+
const handle = window.setInterval(() => {
|
|
224
|
+
setHoldNow(Date.now());
|
|
225
|
+
}, 1000);
|
|
226
|
+
return () => window.clearInterval(handle);
|
|
227
|
+
}, [holdExpiresAt]);
|
|
228
|
+
const selectedDateSlots = selectedDate ? availabilityByDate.get(selectedDate) ?? [] : [];
|
|
229
|
+
const selectedHeldStaff = availableStaff.find((staffMember) => staffMember._id === heldStaffId) ?? selectedStaff ?? null;
|
|
230
|
+
const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
|
|
231
|
+
const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
|
|
232
|
+
const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
|
|
233
|
+
const canAdvanceStep1 = true;
|
|
234
|
+
const canAdvanceStep2 = Boolean(selectedService);
|
|
235
|
+
const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
|
|
236
|
+
const canSubmit = Boolean(selectedService && selectedDate && selectedSlot && holdId && customerName.trim() && customerEmail.trim()) &&
|
|
237
|
+
!isSubmitting;
|
|
154
238
|
async function handleSlotSelect(slot) {
|
|
155
|
-
if (!selectedServiceId) {
|
|
239
|
+
if (!selectedServiceId || !selectedDate) {
|
|
156
240
|
return;
|
|
157
241
|
}
|
|
158
242
|
setError(null);
|
|
159
243
|
setSuccess(null);
|
|
160
|
-
|
|
244
|
+
const nextSlotKey = `${slot.time}-${slot.endTime}`;
|
|
245
|
+
const previousSlot = selectedSlot;
|
|
246
|
+
const previousHoldId = holdId;
|
|
247
|
+
const previousHoldExpiresAt = holdExpiresAt;
|
|
248
|
+
const previousHeldStaffId = heldStaffId;
|
|
249
|
+
setPendingSlotKey(nextSlotKey);
|
|
250
|
+
const reservationStaffId = selectedStaffId ?? slot.availableStaffIds[0] ?? null;
|
|
251
|
+
if (!reservationStaffId) {
|
|
252
|
+
setPendingSlotKey(null);
|
|
253
|
+
setError('No staff is available for that time.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
setSelectedSlot(slot);
|
|
257
|
+
setHeldStaffId(reservationStaffId);
|
|
258
|
+
if (previousHoldId) {
|
|
161
259
|
await client
|
|
162
260
|
.releasePublicBookingHold({
|
|
163
261
|
siteSlug,
|
|
164
|
-
holdId,
|
|
262
|
+
holdId: previousHoldId,
|
|
165
263
|
sessionToken,
|
|
166
264
|
})
|
|
167
265
|
.catch(() => undefined);
|
|
168
|
-
setHoldId(null);
|
|
169
|
-
setHoldExpiresAt(null);
|
|
170
|
-
setHeldStaffId(null);
|
|
171
|
-
}
|
|
172
|
-
const reservationStaffId = selectedStaffId ?? slot.availableStaffIds[0] ?? null;
|
|
173
|
-
if (!reservationStaffId) {
|
|
174
|
-
setError('No staff is available for that time.');
|
|
175
|
-
return;
|
|
176
266
|
}
|
|
177
|
-
const startTime = Date.parse(`${selectedDate}T${slot.time}:00`);
|
|
178
|
-
const endTime = Date.parse(`${selectedDate}T${slot.endTime}:00`);
|
|
179
267
|
try {
|
|
180
268
|
const result = await client.reservePublicBookingHold({
|
|
181
269
|
siteSlug,
|
|
182
270
|
serviceId: selectedServiceId,
|
|
183
271
|
staffMemberId: reservationStaffId,
|
|
184
|
-
startTime,
|
|
185
|
-
endTime,
|
|
272
|
+
startTime: Date.parse(`${selectedDate}T${slot.time}:00`),
|
|
273
|
+
endTime: Date.parse(`${selectedDate}T${slot.endTime}:00`),
|
|
186
274
|
sessionToken,
|
|
187
275
|
});
|
|
188
276
|
setSelectedSlot(slot);
|
|
277
|
+
setPendingSlotKey(null);
|
|
189
278
|
setHoldId(result.holdId);
|
|
190
279
|
setHoldExpiresAt(result.expiresAt);
|
|
191
280
|
setHeldStaffId(reservationStaffId);
|
|
281
|
+
setError(null);
|
|
192
282
|
}
|
|
193
283
|
catch (nextError) {
|
|
284
|
+
setSelectedSlot(previousSlot);
|
|
285
|
+
setHoldId(previousHoldId);
|
|
286
|
+
setHoldExpiresAt(previousHoldExpiresAt);
|
|
287
|
+
setHeldStaffId(previousHeldStaffId);
|
|
288
|
+
setPendingSlotKey(null);
|
|
194
289
|
setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
|
|
195
290
|
}
|
|
196
291
|
}
|
|
197
292
|
async function handleConfirmBooking() {
|
|
198
|
-
if (!selectedServiceId || !
|
|
293
|
+
if (!selectedServiceId || !selectedService || !selectedDate || !selectedSlot || !holdId) {
|
|
199
294
|
return;
|
|
200
295
|
}
|
|
201
296
|
setIsSubmitting(true);
|
|
202
297
|
setError(null);
|
|
203
|
-
setSuccess(null);
|
|
204
298
|
try {
|
|
205
299
|
await client.createPublicBooking({
|
|
206
300
|
siteSlug,
|
|
@@ -208,17 +302,18 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
208
302
|
staffMemberId: heldStaffId ?? selectedStaffId ?? selectedSlot.availableStaffIds[0],
|
|
209
303
|
startTime: Date.parse(`${selectedDate}T${selectedSlot.time}:00`),
|
|
210
304
|
endTime: Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`),
|
|
211
|
-
timezone:
|
|
305
|
+
timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? 'UTC',
|
|
212
306
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
213
|
-
customerName,
|
|
214
|
-
customerEmail,
|
|
215
|
-
customerPhone: customerPhone || undefined,
|
|
216
|
-
customerNotes: customerNotes || undefined,
|
|
307
|
+
customerName: customerName.trim(),
|
|
308
|
+
customerEmail: customerEmail.trim(),
|
|
309
|
+
customerPhone: customerPhone.trim() || undefined,
|
|
310
|
+
customerNotes: customerNotes.trim() || undefined,
|
|
217
311
|
bookingHoldId: holdId,
|
|
218
312
|
bookingSessionToken: sessionToken,
|
|
219
313
|
});
|
|
220
314
|
setSuccess('Booking confirmed.');
|
|
221
315
|
setSelectedSlot(null);
|
|
316
|
+
setPendingSlotKey(null);
|
|
222
317
|
setHoldId(null);
|
|
223
318
|
setHoldExpiresAt(null);
|
|
224
319
|
setHeldStaffId(null);
|
|
@@ -226,6 +321,7 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
226
321
|
setCustomerEmail('');
|
|
227
322
|
setCustomerPhone('');
|
|
228
323
|
setCustomerNotes('');
|
|
324
|
+
setMobileStep(4);
|
|
229
325
|
}
|
|
230
326
|
catch (nextError) {
|
|
231
327
|
setError(nextError instanceof Error ? nextError.message : 'Unable to confirm booking.');
|
|
@@ -235,76 +331,312 @@ export function BookingWidgetPanel({ title, description, }) {
|
|
|
235
331
|
}
|
|
236
332
|
}
|
|
237
333
|
if (isSetupLoading) {
|
|
238
|
-
return (_jsx("
|
|
334
|
+
return (_jsx("section", { className: "bw", children: _jsx(BookingWidgetSkeleton, {}) }));
|
|
239
335
|
}
|
|
240
336
|
if (!setup || setup.services.length === 0) {
|
|
241
|
-
return (_jsx("
|
|
337
|
+
return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
|
|
338
|
+
}
|
|
339
|
+
if (success) {
|
|
340
|
+
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(CheckIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: "You're All Set!" }), _jsx("p", { className: "bw-done-text", children: "Your booking request has been submitted. We'll follow up using the email you provided if anything needs confirmation." }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
|
|
341
|
+
setSuccess(null);
|
|
342
|
+
setSelectedDate(findFirstAvailableDate(availabilityByDate, calendarMonth));
|
|
343
|
+
}, children: "Book another visit" })] }) }));
|
|
242
344
|
}
|
|
243
|
-
return (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
345
|
+
return (_jsxs("section", { className: "bw", children: [mobileHeader ? _jsx("div", { className: "bw-mobile-header", children: mobileHeader }) : null, _jsxs("div", { className: "bw-header", children: [_jsxs("div", { className: "bw-header-row", children: [_jsx("h2", { className: "bw-title bw-title--desktop", children: title || 'Schedule your visit' }), _jsx("h2", { className: "bw-title bw-title--mobile", children: mobileStepTitle(mobileStep) }), _jsx("div", { className: "bw-progress", "aria-hidden": "true", children: MOBILE_PROGRESS_STEPS.map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("label", { className: "bw-label", children: "Select Specialist" }), _jsxs("div", { className: "bw-staff-dropdown", ref: staffMenuRef, children: [_jsxs("button", { type: "button", className: `bw-staff-trigger${selectedStaff ? ' has-value' : ''}`, onClick: () => setStaffOpen((current) => !current), children: [_jsxs("span", { className: "bw-staff-trigger-content", children: [_jsx("span", { className: "bw-staff-avatar", children: selectedStaff ? (_jsx("span", { className: "bw-staff-initials", children: getInitials(selectedStaff.name) })) : (_jsx(UserIcon, {})) }), _jsxs("span", { className: "bw-staff-trigger-info", children: [_jsx("span", { className: "bw-staff-trigger-name", children: selectedStaff ? selectedStaff.name : 'Any available specialist' }), _jsx("span", { className: "bw-staff-trigger-desc", children: selectedStaff ? selectedStaff.timezone : 'First available team member' })] })] }), _jsx("span", { className: "bw-staff-trigger-chevron", children: _jsx(ChevronDownIcon, {}) })] }), staffOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-staff-overlay", "aria-label": "Close specialist menu", onClick: () => setStaffOpen(false) }), _jsxs("div", { className: "bw-staff-list", children: [_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => {
|
|
346
|
+
setSelectedStaffId(null);
|
|
347
|
+
setStaffOpen(false);
|
|
348
|
+
}, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx(UserIcon, {}) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: "Any Available" }), _jsx("span", { className: "bw-staff-option-desc", children: "First available specialist" })] })] }), availableStaff.map((staffMember) => (_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === staffMember._id ? ' is-active' : ''}`, onClick: () => {
|
|
349
|
+
setSelectedStaffId(staffMember._id);
|
|
350
|
+
setStaffOpen(false);
|
|
351
|
+
}, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) }) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: staffMember.name }), _jsx("span", { className: "bw-staff-option-desc", children: staffMember.timezone })] })] }, staffMember._id)))] })] })) : null] }), _jsxs("div", { className: "bw-service-picker", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: "Select Service" }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: setup.services.map((service) => {
|
|
352
|
+
const isActive = selectedServiceId === service._id;
|
|
353
|
+
return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
|
|
354
|
+
void prefetchAvailability({
|
|
355
|
+
client,
|
|
356
|
+
siteSlug,
|
|
357
|
+
selectedServiceId: service._id,
|
|
358
|
+
selectedStaffId,
|
|
359
|
+
calendarMonth,
|
|
360
|
+
cacheRef: availabilityCacheRef,
|
|
361
|
+
});
|
|
362
|
+
}, onFocus: () => {
|
|
363
|
+
void prefetchAvailability({
|
|
364
|
+
client,
|
|
365
|
+
siteSlug,
|
|
366
|
+
selectedServiceId: service._id,
|
|
367
|
+
selectedStaffId,
|
|
368
|
+
calendarMonth,
|
|
369
|
+
cacheRef: availabilityCacheRef,
|
|
370
|
+
});
|
|
371
|
+
}, onClick: () => {
|
|
372
|
+
setSelectedServiceId(service._id);
|
|
373
|
+
setMobileStep(2);
|
|
374
|
+
}, children: [_jsx("span", { className: `bw-check${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null }), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), _jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null] }), _jsx("span", { className: "bw-svc-price", children: currencyFormatter(service.currency).format(service.priceCents / 100) })] }, service._id));
|
|
375
|
+
}) }) }) })] })] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: "Choose your service" }), _jsx("div", { className: "bw-step-2-divider" }), setup.services.map((service) => {
|
|
376
|
+
const isActive = selectedServiceId === service._id;
|
|
377
|
+
return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
|
|
378
|
+
void prefetchAvailability({
|
|
379
|
+
client,
|
|
380
|
+
siteSlug,
|
|
381
|
+
selectedServiceId: service._id,
|
|
382
|
+
selectedStaffId,
|
|
383
|
+
calendarMonth,
|
|
384
|
+
cacheRef: availabilityCacheRef,
|
|
385
|
+
});
|
|
386
|
+
}, onFocus: () => {
|
|
387
|
+
void prefetchAvailability({
|
|
388
|
+
client,
|
|
389
|
+
siteSlug,
|
|
390
|
+
selectedServiceId: service._id,
|
|
391
|
+
selectedStaffId,
|
|
392
|
+
calendarMonth,
|
|
393
|
+
cacheRef: availabilityCacheRef,
|
|
394
|
+
});
|
|
395
|
+
}, onClick: () => setSelectedServiceId(service._id), children: [_jsx("span", { className: `bw-check bw-check--lg${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null }), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), _jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null] }), _jsx("span", { className: "bw-svc-price", children: currencyFormatter(service.currency).format(service.priceCents / 100) })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
|
|
396
|
+
})] })] }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card", children: [_jsxs("div", { className: "bw-cal-header", children: [_jsxs("div", { className: "bw-month-dropdown", children: [_jsxs("button", { type: "button", className: "bw-month-btn", onClick: () => setMonthOpen((current) => !current), children: [_jsx("span", { children: formatMonthLabel(calendarMonth) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": "Close month picker", onClick: () => setMonthOpen(false) }), _jsx("div", { className: "bw-month-list", children: monthOptions.map((option) => (_jsx("button", { type: "button", className: `bw-month-option${option.value === optionCurrentValue(calendarMonth) ? ' is-active' : ''}`, onClick: () => {
|
|
397
|
+
setCalendarMonth(option.date);
|
|
398
|
+
setMonthOpen(false);
|
|
399
|
+
}, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
|
|
400
|
+
if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
|
|
401
|
+
void prefetchAvailability({
|
|
402
|
+
client,
|
|
403
|
+
siteSlug,
|
|
404
|
+
selectedServiceId,
|
|
405
|
+
selectedStaffId,
|
|
406
|
+
calendarMonth: addMonths(calendarMonth, -1),
|
|
407
|
+
cacheRef: availabilityCacheRef,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": "Previous month", children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
|
|
411
|
+
if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
|
|
412
|
+
void prefetchAvailability({
|
|
413
|
+
client,
|
|
414
|
+
siteSlug,
|
|
415
|
+
selectedServiceId,
|
|
416
|
+
selectedStaffId,
|
|
417
|
+
calendarMonth: addMonths(calendarMonth, 1),
|
|
418
|
+
cacheRef: availabilityCacheRef,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": "Next month", children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
|
|
422
|
+
const dateKey = formatDateKey(day);
|
|
423
|
+
const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
|
|
424
|
+
const isPast = dateKey < formatDateKey(startOfDay(new Date()));
|
|
425
|
+
const slots = availabilityByDate.get(dateKey) ?? [];
|
|
426
|
+
const isAvailable = slots.length > 0;
|
|
427
|
+
const isSelected = selectedDate === dateKey;
|
|
428
|
+
const className = [
|
|
429
|
+
'bw-cal-day',
|
|
430
|
+
!isCurrentMonth ? 'is-outside' : '',
|
|
431
|
+
isSelected ? 'is-selected' : '',
|
|
432
|
+
isAvailable && !isPast ? 'is-available' : '',
|
|
433
|
+
isPast ? 'is-disabled' : '',
|
|
434
|
+
!isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
|
|
435
|
+
dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
|
|
436
|
+
]
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.join(' ');
|
|
439
|
+
return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
|
|
440
|
+
setSelectedDate(dateKey);
|
|
441
|
+
setSelectedSlot(null);
|
|
442
|
+
setMobileStep((current) => (current < 3 ? 3 : current));
|
|
443
|
+
}, children: day.getDate() }, dateKey));
|
|
444
|
+
}) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: "Please select a service to see availability." })) : null, selectedService && isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : null, selectedService && selectedDate && !isAvailabilityLoading ? (selectedDateSlots.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "bw-time-divider" }), _jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot) => {
|
|
445
|
+
const slotKey = `${slot.time}-${slot.endTime}`;
|
|
446
|
+
const isPending = pendingSlotKey === slotKey;
|
|
447
|
+
const isActive = isPending ||
|
|
448
|
+
(selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
|
|
449
|
+
return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? 'Securing...' : formatTimeLabel(slot.time) }, slotKey));
|
|
450
|
+
}) })] })) : (_jsx("p", { className: "bw-no-slots", children: "No available times for this date." }))) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? 'UTC' })] })] }) }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-form", children: [_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: "bw-name", children: ["Full name ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: "bw-name", value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "John Doe" })] }), _jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: "bw-email", children: ["Email ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: "bw-email", type: "email", value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), placeholder: "jane@example.com" })] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: "bw-phone", children: "Phone" }), _jsx("input", { id: "bw-phone", value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "+1 (555) 000-0000" })] })] }), _jsxs("div", { className: "bw-field bw-field--notes", children: [_jsx("label", { htmlFor: "bw-notes", children: "Additional notes" }), _jsx("textarea", { id: "bw-notes", rows: 3, maxLength: 500, value: customerNotes, onChange: (event) => setCustomerNotes(event.target.value), placeholder: "Any preferences or special requests..." }), _jsxs("span", { className: "bw-char-count", children: [customerNotes.length, "/500"] })] }), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: "Booking Summary" }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Specialist" }), _jsx("span", { className: "bw-summary-val", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: "Estimated total" }), _jsx("span", { className: "bw-summary-val", children: currencyFormatter(selectedService.currency).format(selectedService.priceCents / 100) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, _jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting ? 'Booking...' : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-footer", children: [mobileStep === 4 && selectedService ? (_jsxs("div", { className: "bw-footer-summary", children: [_jsx("span", { className: "bw-summary-title", children: "Booking Summary" }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Specialist" }), _jsx("span", { className: "bw-summary-val", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: "Estimated total" }), _jsx("span", { className: "bw-summary-val", children: currencyFormatter(selectedService.currency).format(selectedService.priceCents / 100) })] })] })] })) : null, _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => setMobileStep((step) => Math.max(1, step - 1)), children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
|
|
451
|
+
(mobileStep === 2 && !canAdvanceStep2) ||
|
|
452
|
+
(mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting ? 'Booking...' : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
|
|
453
|
+
}
|
|
454
|
+
function BookingWidgetSkeleton() {
|
|
455
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bw-header", children: [_jsx("div", { className: "bw-skel bw-skel--title" }), _jsx("div", { className: "bw-skel bw-skel--text bw-skel--text-lg" })] }), _jsxs("div", { className: "bw-body bw-skel-desktop", children: [_jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel-list", children: Array.from({ length: 5 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--card" }, index))) })] }), _jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--calendar" }), _jsx("div", { className: "bw-skel-slots", children: _jsx("div", { className: "bw-skel-slots-grid", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }) })] }), _jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--textarea" })] })] }), _jsxs("div", { className: "bw-skel-mobile", children: [_jsx("div", { className: "bw-skel bw-skel--title" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--card" }), _jsx("div", { className: "bw-skel bw-skel--card" }), _jsx("div", { className: "bw-skel bw-skel--card" })] })] }));
|
|
456
|
+
}
|
|
457
|
+
function AvailabilitySkeleton() {
|
|
458
|
+
return (_jsx("div", { className: "bw-skel-slots", children: _jsx("div", { className: "bw-skel-slots-grid", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }) }));
|
|
459
|
+
}
|
|
460
|
+
function mobileStepTitle(step) {
|
|
461
|
+
switch (step) {
|
|
462
|
+
case 1:
|
|
463
|
+
return 'Schedule your visit';
|
|
464
|
+
case 2:
|
|
465
|
+
return 'Select your service';
|
|
466
|
+
case 3:
|
|
467
|
+
return 'Pick a date & time';
|
|
468
|
+
case 4:
|
|
469
|
+
return 'Your details';
|
|
470
|
+
default:
|
|
471
|
+
return 'Booking';
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function formatDuration(minutes) {
|
|
475
|
+
if (minutes >= 60 && minutes % 60 === 0) {
|
|
476
|
+
return `${minutes / 60} hr`;
|
|
477
|
+
}
|
|
478
|
+
return `${minutes} min`;
|
|
276
479
|
}
|
|
277
480
|
function currencyFormatter(currency) {
|
|
278
481
|
return new Intl.NumberFormat('en-US', {
|
|
279
482
|
style: 'currency',
|
|
280
|
-
currency: currency
|
|
281
|
-
|
|
483
|
+
currency: currency || 'USD',
|
|
484
|
+
minimumFractionDigits: 0,
|
|
282
485
|
});
|
|
283
486
|
}
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
487
|
+
function sessionTokenFromBrowser() {
|
|
488
|
+
if (typeof window === 'undefined') {
|
|
489
|
+
return 'server-session';
|
|
490
|
+
}
|
|
491
|
+
const existing = window.localStorage.getItem('sable-public-booking-session');
|
|
492
|
+
if (existing) {
|
|
493
|
+
return existing;
|
|
494
|
+
}
|
|
495
|
+
const next = `session_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
496
|
+
window.localStorage.setItem('sable-public-booking-session', next);
|
|
497
|
+
return next;
|
|
498
|
+
}
|
|
499
|
+
function getInitials(name) {
|
|
500
|
+
return name
|
|
501
|
+
.split(/\s+/)
|
|
502
|
+
.filter(Boolean)
|
|
503
|
+
.map((part) => part[0])
|
|
504
|
+
.join('')
|
|
505
|
+
.slice(0, 2)
|
|
506
|
+
.toUpperCase();
|
|
507
|
+
}
|
|
508
|
+
function formatDateKey(date) {
|
|
509
|
+
const year = date.getFullYear();
|
|
510
|
+
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
|
511
|
+
const day = `${date.getDate()}`.padStart(2, '0');
|
|
512
|
+
return `${year}-${month}-${day}`;
|
|
288
513
|
}
|
|
289
|
-
function
|
|
290
|
-
return new
|
|
514
|
+
function formatMonthLabel(date) {
|
|
515
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
516
|
+
month: 'long',
|
|
517
|
+
year: 'numeric',
|
|
518
|
+
}).format(date);
|
|
519
|
+
}
|
|
520
|
+
function formatReadableDate(dateKey) {
|
|
521
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
291
522
|
weekday: 'short',
|
|
292
523
|
month: 'short',
|
|
293
524
|
day: 'numeric',
|
|
525
|
+
}).format(new Date(`${dateKey}T00:00:00`));
|
|
526
|
+
}
|
|
527
|
+
function formatTimeLabel(time) {
|
|
528
|
+
const [hourString, minuteString] = time.split(':');
|
|
529
|
+
const hour = Number(hourString);
|
|
530
|
+
const minute = Number(minuteString);
|
|
531
|
+
const date = new Date();
|
|
532
|
+
date.setHours(hour, minute, 0, 0);
|
|
533
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
534
|
+
hour: 'numeric',
|
|
535
|
+
minute: '2-digit',
|
|
536
|
+
}).format(date);
|
|
537
|
+
}
|
|
538
|
+
function startOfMonth(date) {
|
|
539
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
540
|
+
}
|
|
541
|
+
function endOfMonth(date) {
|
|
542
|
+
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
|
543
|
+
}
|
|
544
|
+
function startOfDay(date) {
|
|
545
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
546
|
+
}
|
|
547
|
+
function addMonths(date, amount) {
|
|
548
|
+
return new Date(date.getFullYear(), date.getMonth() + amount, 1);
|
|
549
|
+
}
|
|
550
|
+
function buildCalendarDays(month) {
|
|
551
|
+
const first = startOfMonth(month);
|
|
552
|
+
const start = new Date(first);
|
|
553
|
+
start.setDate(first.getDate() - first.getDay());
|
|
554
|
+
return Array.from({ length: 42 }, (_, index) => {
|
|
555
|
+
const next = new Date(start);
|
|
556
|
+
next.setDate(start.getDate() + index);
|
|
557
|
+
return next;
|
|
294
558
|
});
|
|
295
559
|
}
|
|
296
|
-
function
|
|
297
|
-
|
|
298
|
-
|
|
560
|
+
function sameMonth(left, right) {
|
|
561
|
+
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
|
|
562
|
+
}
|
|
563
|
+
function getMonthOptions(currentMonth, total) {
|
|
564
|
+
const start = startOfMonth(new Date());
|
|
565
|
+
return Array.from({ length: total }, (_, index) => {
|
|
566
|
+
const date = addMonths(start, index);
|
|
567
|
+
return {
|
|
568
|
+
value: optionCurrentValue(date),
|
|
569
|
+
label: formatMonthLabel(date),
|
|
570
|
+
date,
|
|
571
|
+
isCurrent: sameMonth(date, currentMonth),
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function optionCurrentValue(date) {
|
|
576
|
+
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
|
577
|
+
}
|
|
578
|
+
function isDateInMonth(dateKey, month) {
|
|
579
|
+
const date = new Date(`${dateKey}T00:00:00`);
|
|
580
|
+
return date.getMonth() === month.getMonth() && date.getFullYear() === month.getFullYear();
|
|
581
|
+
}
|
|
582
|
+
function findFirstAvailableDate(availabilityByDate, month) {
|
|
583
|
+
const dates = [...availabilityByDate.entries()]
|
|
584
|
+
.filter(([dateKey, slots]) => slots.length > 0 && isDateInMonth(dateKey, month))
|
|
585
|
+
.map(([dateKey]) => dateKey)
|
|
586
|
+
.sort();
|
|
587
|
+
return dates[0] ?? null;
|
|
588
|
+
}
|
|
589
|
+
async function prefetchAvailability({ client, siteSlug, selectedServiceId, selectedStaffId, calendarMonth, cacheRef, }) {
|
|
590
|
+
const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
|
|
591
|
+
if (cacheRef.current.has(requestKey)) {
|
|
592
|
+
return;
|
|
299
593
|
}
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
594
|
+
const result = await client.getPublicAvailableSlots({
|
|
595
|
+
siteSlug,
|
|
596
|
+
serviceId: selectedServiceId,
|
|
597
|
+
staffMemberId: selectedStaffId ?? undefined,
|
|
598
|
+
startDate: formatDateKey(calendarMonth),
|
|
599
|
+
endDate: formatDateKey(endOfMonth(calendarMonth)),
|
|
600
|
+
});
|
|
601
|
+
const nextMap = new Map();
|
|
602
|
+
for (const day of result.dates) {
|
|
603
|
+
nextMap.set(day.date, day.slots.filter((slot) => (slot.availableStaffIds?.length ?? 0) > 0));
|
|
303
604
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
605
|
+
cacheRef.current.set(requestKey, nextMap);
|
|
606
|
+
}
|
|
607
|
+
function getAvailabilityCacheKey(siteSlug, serviceId, staffId, month) {
|
|
608
|
+
return `${siteSlug}:${serviceId}:${staffId ?? 'any'}:${optionCurrentValue(month)}`;
|
|
609
|
+
}
|
|
610
|
+
function formatHoldCountdown(totalSeconds) {
|
|
611
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
612
|
+
const seconds = totalSeconds % 60;
|
|
613
|
+
return `${minutes}:${`${seconds}`.padStart(2, '0')}`;
|
|
614
|
+
}
|
|
615
|
+
function iconPath(path) {
|
|
616
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: path }) }));
|
|
617
|
+
}
|
|
618
|
+
function ChevronDownIcon() {
|
|
619
|
+
return iconPath('m6 9 6 6 6-6');
|
|
620
|
+
}
|
|
621
|
+
function ChevronLeftIcon() {
|
|
622
|
+
return iconPath('m15 18-6-6 6-6');
|
|
623
|
+
}
|
|
624
|
+
function ChevronRightIcon() {
|
|
625
|
+
return iconPath('m9 18 6-6-6-6');
|
|
626
|
+
}
|
|
627
|
+
function ArrowLeftIcon() {
|
|
628
|
+
return iconPath('M19 12H5m7 7-7-7 7-7');
|
|
629
|
+
}
|
|
630
|
+
function ArrowRightIcon() {
|
|
631
|
+
return iconPath('M5 12h14m-7-7 7 7-7 7');
|
|
632
|
+
}
|
|
633
|
+
function CheckIcon() {
|
|
634
|
+
return iconPath('M20 6 9 17l-5-5');
|
|
635
|
+
}
|
|
636
|
+
function UserIcon() {
|
|
637
|
+
return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
|
|
638
|
+
}
|
|
639
|
+
function GlobeIcon() {
|
|
640
|
+
return iconPath('M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 0c2.5 2.7 4 6.2 4 10s-1.5 7.3-4 10c-2.5-2.7-4-6.2-4-10s1.5-7.3 4-10ZM2 12h20');
|
|
309
641
|
}
|
|
310
642
|
//# sourceMappingURL=booking-widget.js.map
|