@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.
@@ -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
- export function BookingWidgetPanel({ title, description, }) {
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 [selectedDate, setSelectedDate] = useState(() => dateKeyFromOffset(0));
12
- const [dateSlots, setDateSlots] = useState([]);
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
- setDateSlots([]);
95
+ setAvailabilityByDate(new Map());
96
+ setSelectedDate(null);
72
97
  return;
73
98
  }
74
99
  let cancelled = false;
75
- setIsSlotsLoading(true);
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: selectedDate,
82
- endDate: selectedDate,
121
+ startDate: monthStart,
122
+ endDate: monthEnd,
83
123
  })
84
124
  .then((result) => {
85
- if (!cancelled) {
86
- setDateSlots(result.dates[0]?.slots ?? []);
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
- setDateSlots([]);
92
- setError(nextError instanceof Error ? nextError.message : 'Unable to load booking slots.');
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
- setIsSlotsLoading(false);
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
- const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - Date.now()) / 1000)) : null;
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
- if (holdId) {
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 || !selectedSlot || !holdId || !selectedService) {
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: selectedStaff?.timezone ?? availableStaff[0]?.timezone ?? 'UTC',
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("div", { className: "rounded-[28px] border border-black/8 bg-white p-6 text-sm text-stone-500", children: "Loading booking..." }));
339
+ return (_jsx("section", { className: "bw", children: _jsx(BookingWidgetSkeleton, {}) }));
239
340
  }
240
341
  if (!setup || setup.services.length === 0) {
241
- return (_jsx("div", { className: "rounded-[28px] border border-black/8 bg-white p-6 text-sm text-stone-500", children: "No bookable services are available yet." }));
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 (_jsx("section", { className: "mx-auto w-full max-w-[1440px] px-4 py-8 sm:px-6 lg:px-8", children: _jsxs("div", { className: "grid gap-6 rounded-[32px] border border-black/8 bg-white p-5 shadow-sm lg:grid-cols-[1.15fr_0.85fr] lg:p-8", children: [_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-[0.22em] text-stone-500", children: "Booking" }), _jsx("h2", { className: "mt-2 text-3xl font-semibold tracking-[-0.04em] text-stone-950", children: title || `Book with ${setup.site.businessName}` }), _jsx("p", { className: "mt-3 max-w-2xl text-sm leading-6 text-stone-600", children: description || 'Choose a service, reserve a slot, and confirm the booking directly through Sable.' })] }), _jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-stone-500", children: "1. Choose a service" }), _jsx("div", { className: "mt-3 grid gap-3 sm:grid-cols-2", children: setup.services.map((service) => {
244
- const isActive = service._id === selectedServiceId;
245
- const priceLabel = currencyFormatter(service.currency).format(service.priceCents / 100);
246
- return (_jsxs("button", { type: "button", onClick: () => setSelectedServiceId(service._id), className: isActive
247
- ? 'rounded-[24px] border border-stone-950 bg-stone-950 p-4 text-left text-white'
248
- : 'rounded-[24px] border border-black/8 bg-stone-50 p-4 text-left text-stone-950 transition hover:border-stone-300 hover:bg-white', children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "text-base font-semibold", children: service.name }), _jsx("p", { className: isActive ? 'mt-2 text-sm text-white/72' : 'mt-2 text-sm text-stone-600', children: service.description || 'Book this service online.' })] }), _jsxs("span", { className: isActive ? 'text-xs font-medium text-white/72' : 'text-xs font-medium text-stone-500', children: [service.durationMinutes, " min"] })] }), _jsx("p", { className: isActive ? 'mt-4 text-sm font-medium text-white' : 'mt-4 text-sm font-medium text-stone-950', children: priceLabel })] }, service._id));
249
- }) })] }), _jsxs("div", { className: "grid gap-5 lg:grid-cols-[0.72fr_1.28fr]", children: [_jsxs("div", { className: "space-y-5", children: [_jsxs("div", { children: [_jsx("label", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-stone-500", children: "2. Choose a team member" }), _jsxs("div", { className: "mt-3 grid gap-2", children: [_jsxs("button", { type: "button", onClick: () => setSelectedStaffId(null), className: selectedStaffId === null
250
- ? 'flex min-h-12 items-center justify-between rounded-2xl bg-stone-950 px-4 text-left text-sm font-medium text-white'
251
- : 'flex min-h-12 items-center justify-between rounded-2xl border border-black/8 bg-white px-4 text-left text-sm font-medium text-stone-900 transition hover:border-stone-300 hover:bg-stone-50', children: [_jsx("span", { children: "Any available" }), _jsx("span", { className: selectedStaffId === null ? 'text-white/70' : 'text-stone-500', children: "Fastest option" })] }), availableStaff.map((staffMember) => {
252
- const isSelected = staffMember._id === selectedStaffId;
253
- return (_jsxs("button", { type: "button", onClick: () => setSelectedStaffId(staffMember._id), className: isSelected
254
- ? 'flex min-h-12 items-center justify-between rounded-2xl bg-stone-950 px-4 text-left text-sm font-medium text-white'
255
- : 'flex min-h-12 items-center justify-between rounded-2xl border border-black/8 bg-white px-4 text-left text-sm font-medium text-stone-900 transition hover:border-stone-300 hover:bg-stone-50', children: [_jsx("span", { children: staffMember.name }), _jsx("span", { className: isSelected ? 'text-white/70' : 'text-stone-500', children: staffMember.timezone })] }, staffMember._id));
256
- })] })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-stone-500", children: "3. Choose a date" }), _jsx("div", { className: "grid gap-2 sm:grid-cols-3 lg:grid-cols-1", children: nextDateOptions.map((dateKey) => {
257
- const isSelected = selectedDate === dateKey;
258
- return (_jsxs("button", { type: "button", onClick: () => setSelectedDate(dateKey), className: isSelected
259
- ? 'flex min-h-12 items-center justify-between rounded-2xl bg-stone-950 px-4 text-left text-sm font-medium text-white'
260
- : 'flex min-h-12 items-center justify-between rounded-2xl border border-black/8 bg-white px-4 text-left text-sm font-medium text-stone-900 transition hover:border-stone-300 hover:bg-stone-50', children: [_jsx("span", { children: formatDateLabel(dateKey) }), _jsx("span", { className: isSelected ? 'text-white/70' : 'text-stone-500', children: dateKey === nextDateOptions[0] ? 'Today' : '' })] }, dateKey));
261
- }) }), _jsx("input", { type: "date", value: selectedDate, onChange: (event) => setSelectedDate(event.target.value), className: "h-12 w-full rounded-2xl border border-black/8 px-4 text-sm text-stone-950 outline-none transition focus:border-stone-400" }), holdExpiresAt ? _jsxs("p", { className: "text-xs text-stone-500", children: ["Slot held for ", holdSecondsRemaining, "s"] }) : null] })] }), _jsxs("div", { children: [_jsx("label", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-stone-500", children: "4. Choose a time" }), _jsx("div", { className: "mt-3 grid gap-2 sm:grid-cols-3 lg:grid-cols-4", children: isSlotsLoading ? (_jsx("div", { className: "rounded-2xl border border-dashed border-black/10 bg-stone-50 px-4 py-6 text-sm text-stone-500 sm:col-span-3 lg:col-span-4", children: "Loading available slots..." })) : dateSlots.length > 0 ? (dateSlots.map((slot) => {
262
- const isSelected = selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime;
263
- return (_jsx("button", { type: "button", onClick: () => void handleSlotSelect(slot), className: isSelected
264
- ? 'h-11 rounded-2xl bg-stone-950 px-3 text-sm font-medium text-white'
265
- : 'h-11 rounded-2xl border border-black/8 bg-white px-3 text-sm font-medium text-stone-900 transition hover:border-stone-300 hover:bg-stone-50', children: slot.time }, `${slot.time}-${slot.endTime}`));
266
- })) : (_jsx("div", { className: "rounded-2xl border border-dashed border-black/10 bg-stone-50 px-4 py-6 text-sm text-stone-500 sm:col-span-3 lg:col-span-4", children: "No slots available for this date yet." })) })] })] })] }), _jsxs("div", { className: "rounded-[28px] bg-stone-50 p-5 lg:p-6", children: [_jsx("h3", { className: "text-lg font-semibold text-stone-950", children: "Your details" }), _jsxs("div", { className: "mt-5 space-y-3", children: [_jsx("input", { value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "Full name", className: "h-12 w-full rounded-2xl border border-black/8 bg-white px-4 text-sm text-stone-950 outline-none transition focus:border-stone-400" }), _jsx("input", { value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), placeholder: "Email", type: "email", className: "h-12 w-full rounded-2xl border border-black/8 bg-white px-4 text-sm text-stone-950 outline-none transition focus:border-stone-400" }), _jsx("input", { value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "Phone", className: "h-12 w-full rounded-2xl border border-black/8 bg-white px-4 text-sm text-stone-950 outline-none transition focus:border-stone-400" }), _jsx("textarea", { value: customerNotes, onChange: (event) => setCustomerNotes(event.target.value), placeholder: "Anything we should know?", className: "min-h-28 w-full rounded-2xl border border-black/8 bg-white px-4 py-3 text-sm text-stone-950 outline-none transition focus:border-stone-400" })] }), _jsxs("div", { className: "mt-6 rounded-[24px] border border-black/8 bg-white p-4", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-stone-500", children: "Summary" }), _jsxs("div", { className: "mt-3 space-y-2 text-sm text-stone-700", children: [_jsx("p", { children: selectedService ? selectedService.name : 'Choose a service' }), _jsx("p", { children: selectedSlot ? `${selectedDate} at ${selectedSlot.time}` : 'Choose a time' }), _jsx("p", { children: heldStaffId
267
- ? `Reserved with ${availableStaff.find((staffMember) => staffMember._id === heldStaffId)?.name ?? 'selected staff'}`
268
- : selectedStaff
269
- ? `Staff: ${selectedStaff.name}`
270
- : 'Any available team member' })] })] }), error ? _jsx("p", { className: "mt-4 text-sm text-red-600", children: error }) : null, success ? _jsx("p", { className: "mt-4 text-sm text-emerald-700", children: success }) : null, _jsx("button", { type: "button", disabled: isSubmitting ||
271
- !selectedService ||
272
- !selectedSlot ||
273
- !holdId ||
274
- !customerName.trim() ||
275
- !customerEmail.trim(), onClick: () => void handleConfirmBooking(), className: "mt-6 inline-flex h-12 w-full items-center justify-center rounded-2xl bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-300", children: isSubmitting ? 'Booking...' : 'Confirm booking' })] })] }) }));
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.toUpperCase(),
281
- maximumFractionDigits: 0,
496
+ currency: currency || 'USD',
497
+ minimumFractionDigits: 0,
282
498
  });
283
499
  }
284
- function dateKeyFromOffset(offset) {
285
- const next = new Date();
286
- next.setDate(next.getDate() + offset);
287
- return next.toISOString().slice(0, 10);
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 formatDateLabel(dateKey) {
290
- return new Date(`${dateKey}T00:00:00`).toLocaleDateString([], {
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 sessionTokenFromBrowser() {
297
- if (typeof window === 'undefined') {
298
- return 'server-preview-session';
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 existing = window.sessionStorage.getItem('sable-public-booking-session');
301
- if (existing) {
302
- return existing;
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
- const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
305
- ? crypto.randomUUID()
306
- : `session-${Math.random().toString(36).slice(2)}`;
307
- window.sessionStorage.setItem('sable-public-booking-session', next);
308
- return next;
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