@asksable/site-connector 0.1.1 → 0.1.4

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