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