@asksable/site-connector 0.1.6 → 0.3.0

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,41 +1,160 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { useSableSiteClient, useSableSiteConfig } from './provider.js';
4
- const MOBILE_PROGRESS_STEPS = [1, 2, 3, 4];
5
- export function BookingWidgetPanel({ title, description, mobileHeader, }) {
3
+ import { createPortal } from 'react-dom';
4
+ import { loadStripe } from '@stripe/stripe-js';
5
+ import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
6
+ import { Skeleton } from 'boneyard-js/react';
7
+ // Side-effect: registers all named bones with the boneyard runtime.
8
+ // Regenerate by running `npx boneyard-js build` against a host site
9
+ // that mounts <BookingWidgetPanel /> in its loaded state.
10
+ import './bones/registry.js';
11
+ import { useSableSiteClient, useSableSiteConfig, useTranslation } from './provider.js';
12
+ import { DEFAULT_LOCALE, localeToIntl, pickLocaleField } from './translations.js';
13
+ const stripePromises = new Map();
14
+ function getStripePromise(publishableKey, connectAccountId) {
15
+ const key = `${publishableKey}:${connectAccountId}`;
16
+ const cached = stripePromises.get(key);
17
+ if (cached)
18
+ return cached;
19
+ const promise = loadStripe(publishableKey, { stripeAccount: connectAccountId });
20
+ stripePromises.set(key, promise);
21
+ return promise;
22
+ }
23
+ function isDevRuntime() {
24
+ const env = import.meta.env;
25
+ return env?.DEV === true || env?.MODE === 'development';
26
+ }
27
+ function hasIntakeFormSections(service) {
28
+ return (service?.intakeForm?.sections?.length ?? 0) > 0;
29
+ }
30
+ function findCalculatedServiceMissingIntake(setup) {
31
+ return setup.services.find((service) => service.pricingMode === 'calculated' && !hasIntakeFormSections(service));
32
+ }
33
+ const MOBILE_PROGRESS_STEPS_SCHEDULED = [1, 2, 3, 4];
34
+ const MOBILE_PROGRESS_STEPS_ASYNC = [1, 3, 4];
35
+ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
36
+ const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
37
+ const isReschedule = mode === 'reschedule';
38
+ // Reschedule summary holds longer values (full former-time
39
+ // sentence, "Signature Color Refresh") that the widget's default
40
+ // single-line ellipsis truncates. We override per-row inline so
41
+ // create mode is unchanged.
42
+ const summaryValStyle = isReschedule
43
+ ? {
44
+ whiteSpace: 'normal',
45
+ overflow: 'visible',
46
+ textOverflow: 'clip',
47
+ wordBreak: 'normal',
48
+ overflowWrap: 'anywhere',
49
+ }
50
+ : undefined;
6
51
  const client = useSableSiteClient();
7
52
  const { siteSlug } = useSableSiteConfig();
53
+ const { t, locale } = useTranslation();
54
+ const intlLocale = localeToIntl(locale);
8
55
  const [setup, setSetup] = useState(null);
9
56
  const [isSetupLoading, setIsSetupLoading] = useState(true);
10
- const [selectedServiceId, setSelectedServiceId] = useState(null);
11
- const [selectedStaffId, setSelectedStaffId] = useState(null);
57
+ // In reschedule mode the service is locked to the original
58
+ // appointment's service. We seed selection from rescheduleContext
59
+ // up front so the calendar/slot fetch fires without the user
60
+ // needing to click anything.
61
+ const [selectedServiceId, setSelectedServiceId] = useState(() => rescheduleContext?.serviceId ?? null);
62
+ const [selectedStaffId, setSelectedStaffId] = useState(() => rescheduleContext?.staffMemberId ?? null);
12
63
  const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
13
64
  const [availabilityByDate, setAvailabilityByDate] = useState(new Map());
14
65
  const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
15
66
  const [selectedDate, setSelectedDate] = useState(null);
16
67
  const [selectedSlot, setSelectedSlot] = useState(null);
17
68
  const [pendingSlotKey, setPendingSlotKey] = useState(null);
69
+ // Service picker is progressive: full list shows initially, collapses
70
+ // to a single "selected service" card once a service is picked, then
71
+ // the provider section reveals below. Click Change on the card flips
72
+ // back to full list. Reschedule mode skips the collapse since the
73
+ // service is locked from rescheduleContext at mount.
74
+ const [isEditingService, setIsEditingService] = useState(false);
18
75
  const [holdId, setHoldId] = useState(null);
19
76
  const [holdExpiresAt, setHoldExpiresAt] = useState(null);
20
77
  const [heldStaffId, setHeldStaffId] = useState(null);
21
- const [customerName, setCustomerName] = useState('');
22
- const [customerEmail, setCustomerEmail] = useState('');
23
- const [customerPhone, setCustomerPhone] = useState('');
78
+ // Pre-fill contact fields from the original booking when
79
+ // rescheduling. Editable, since a user might want to fix typos at
80
+ // the same time, but the reschedule flow doesn't actually overwrite
81
+ // them — the host's mutation only patches the time fields.
82
+ const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
83
+ const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
84
+ const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
24
85
  const [customerNotes, setCustomerNotes] = useState('');
86
+ const [intakeResponses, setIntakeResponses] = useState({});
87
+ const [quote, setQuote] = useState(null);
88
+ const [isQuoteLoading, setIsQuoteLoading] = useState(false);
89
+ const [payment, setPayment] = useState(null);
90
+ const [paymentAppointmentId, setPaymentAppointmentId] = useState(null);
25
91
  const [isSubmitting, setIsSubmitting] = useState(false);
92
+ const [submitAttempted, setSubmitAttempted] = useState(false);
26
93
  const [error, setError] = useState(null);
27
94
  const [success, setSuccess] = useState(null);
28
95
  const [mobileStep, setMobileStep] = useState(1);
96
+ const [mobilePaymentActionTarget, setMobilePaymentActionTarget] = useState(null);
97
+ // Two-step desktop flow: 'slots' shows the available-times list,
98
+ // 'details' shows the customer form + summary + confirm. Picking a
99
+ // slot auto-advances to details with a crossfade matching Sable's
100
+ // popup rule (250ms expo ease-out in, 150ms ease-out out). Back
101
+ // button in details returns to slots and releases the hold.
102
+ const [viewState, setViewState] = useState('slots');
103
+ // Dev-only: force viewState to 'details' when previewing a payment
104
+ // variant, since the payment panel renders inside the details form.
105
+ useEffect(() => {
106
+ if (__devForceState === 'payment-full' || __devForceState === 'payment-deposit') {
107
+ setViewState('details');
108
+ }
109
+ }, [__devForceState]);
29
110
  const [monthOpen, setMonthOpen] = useState(false);
30
- const [staffOpen, setStaffOpen] = useState(false);
31
111
  const [holdNow, setHoldNow] = useState(() => Date.now());
32
112
  const [sessionToken] = useState(() => sessionTokenFromBrowser());
33
- const staffMenuRef = useRef(null);
113
+ // Increments on "Book another visit" so the booking section remounts
114
+ // cleanly. Avoids stale-memo / stale-ref edge cases that otherwise
115
+ // can leave the calendar empty until a hard refresh.
116
+ const [bookingFlowKey, setBookingFlowKey] = useState(0);
34
117
  const availabilityCacheRef = useRef(new Map());
35
118
  const holdIdRef = useRef(null);
119
+ // Calendar card lives in the middle column and is the natural
120
+ // height anchor of the desktop layout. We measure it on resize and
121
+ // expose --bw-cal-h on the .bw root so the left (services) and
122
+ // right (slots) columns can cap their internal scrollers to match,
123
+ // keeping all three columns the same visual height regardless of
124
+ // service-list length or slot count.
125
+ const calCardRef = useRef(null);
126
+ const widgetRootRef = useRef(null);
127
+ useEffect(() => {
128
+ const card = calCardRef.current;
129
+ const root = widgetRootRef.current;
130
+ if (!card || !root)
131
+ return;
132
+ const observer = new ResizeObserver((entries) => {
133
+ const entry = entries[0];
134
+ if (!entry)
135
+ return;
136
+ const height = Math.round(entry.contentRect.height);
137
+ root.style.setProperty('--bw-cal-h', `${height}px`);
138
+ });
139
+ observer.observe(card);
140
+ return () => observer.disconnect();
141
+ }, [setup]);
36
142
  useEffect(() => {
37
143
  holdIdRef.current = holdId;
38
144
  }, [holdId]);
145
+ // Reset the mobile body scroll to the top whenever the step changes
146
+ // so the user always lands on the first field of the new step (e.g.
147
+ // step 3's Name input) instead of where the previous step's scroll
148
+ // happened to leave them.
149
+ useEffect(() => {
150
+ const root = widgetRootRef.current;
151
+ if (!root)
152
+ return;
153
+ const body = root.querySelector('.bw-body');
154
+ if (!body)
155
+ return;
156
+ body.scrollTo({ top: 0, behavior: 'smooth' });
157
+ }, [mobileStep]);
39
158
  useEffect(() => {
40
159
  let cancelled = false;
41
160
  setIsSetupLoading(true);
@@ -45,8 +164,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
45
164
  if (cancelled) {
46
165
  return;
47
166
  }
167
+ const missingIntake = findCalculatedServiceMissingIntake(result);
168
+ if (missingIntake && isDevRuntime()) {
169
+ throw new Error(`Calculated service "${missingIntake.name}" is missing intakeForm.sections. Shipping services must provide an intakeForm instead of falling back to the generic contact form.`);
170
+ }
48
171
  setSetup(result);
49
- setSelectedServiceId((current) => current || result.services[0]?._id || null);
172
+ // Don't auto-select a service in create mode — let the user
173
+ // actively pick one. Reschedule mode pre-seeds via the
174
+ // useState initializer above so this preserves that.
50
175
  setError(null);
51
176
  })
52
177
  .catch((nextError) => {
@@ -63,20 +188,91 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
63
188
  cancelled = true;
64
189
  };
65
190
  }, [client, siteSlug]);
66
- useEffect(() => {
67
- function handleDocumentClick(event) {
68
- if (!staffMenuRef.current?.contains(event.target)) {
69
- setStaffOpen(false);
191
+ const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
192
+ const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
193
+ const selectedServicePath = selectedServiceRequiresSlot ? 'scheduled' : 'async';
194
+ const selectedServiceHasIntakeForm = hasIntakeFormSections(selectedService);
195
+ const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
196
+ areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
197
+ const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath, locale), [selectedService, selectedServicePath, locale]);
198
+ const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
199
+ const trimmedCustomerEmail = customerEmail.trim();
200
+ const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
201
+ const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
202
+ // In reschedule mode we pre-select the original service but keep
203
+ // the full list visible — the admin may legitimately need to
204
+ // switch service when rescheduling (e.g. customer asked for a
205
+ // shorter visit alongside the time change).
206
+ const visibleServices = useMemo(() => {
207
+ if (!setup)
208
+ return [];
209
+ return setup.services;
210
+ }, [setup]);
211
+ const serviceGroups = useMemo(() => {
212
+ if (!setup || visibleServices.length === 0)
213
+ return [];
214
+ const categoriesById = new Map((setup.categories ?? []).map((c) => [c._id, c]));
215
+ const groups = new Map();
216
+ const uncategorizedKey = '__uncategorized__';
217
+ for (const service of visibleServices) {
218
+ const category = service.categoryId != null
219
+ ? categoriesById.get(service.categoryId) ?? null
220
+ : null;
221
+ const key = category?._id ?? uncategorizedKey;
222
+ const existing = groups.get(key);
223
+ if (existing) {
224
+ existing.services.push(service);
225
+ }
226
+ else {
227
+ groups.set(key, {
228
+ key,
229
+ categoryName: category?.name ?? null,
230
+ services: [service],
231
+ });
70
232
  }
71
233
  }
72
- if (staffOpen) {
73
- document.addEventListener('mousedown', handleDocumentClick);
234
+ // Order: categories by their configured sortOrder, then any
235
+ // uncategorized bucket at the end.
236
+ const orderedCategoryKeys = (setup.categories ?? []).map((c) => c._id);
237
+ const ordered = [];
238
+ for (const id of orderedCategoryKeys) {
239
+ const g = groups.get(id);
240
+ if (g)
241
+ ordered.push(g);
74
242
  }
75
- return () => {
76
- document.removeEventListener('mousedown', handleDocumentClick);
77
- };
78
- }, [staffOpen]);
79
- const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
243
+ const uncategorized = groups.get(uncategorizedKey);
244
+ if (uncategorized)
245
+ ordered.push(uncategorized);
246
+ // If the workspace has no categories at all, return a single
247
+ // group with no header so the picker still reads as one list.
248
+ if (ordered.length === 1 && ordered[0].categoryName === null) {
249
+ return ordered;
250
+ }
251
+ return ordered;
252
+ }, [setup, visibleServices]);
253
+ // Original service / staff display names (looked up against the
254
+ // loaded setup), used in the reschedule summary's "Former …" rows.
255
+ // These render only when the user changes the corresponding field
256
+ // away from the original — picking only a new time leaves them
257
+ // hidden, so the summary stays clean for the common case.
258
+ const formerService = useMemo(() => {
259
+ if (!isReschedule || !rescheduleContext || !setup)
260
+ return null;
261
+ return (setup.services.find((s) => s._id === rescheduleContext.serviceId) ?? null);
262
+ }, [isReschedule, rescheduleContext, setup]);
263
+ const formerStaffName = useMemo(() => {
264
+ if (!isReschedule || !rescheduleContext?.staffMemberId || !setup) {
265
+ return null;
266
+ }
267
+ return (setup.staff.find((s) => s._id === rescheduleContext.staffMemberId)
268
+ ?.name ?? null);
269
+ }, [isReschedule, rescheduleContext, setup]);
270
+ const serviceChanged = isReschedule &&
271
+ rescheduleContext != null &&
272
+ selectedServiceId !== rescheduleContext.serviceId;
273
+ const staffChanged = isReschedule &&
274
+ rescheduleContext != null &&
275
+ (selectedStaffId ?? null) !== (rescheduleContext.staffMemberId ?? null);
80
276
  const availableStaff = useMemo(() => {
81
277
  if (!setup || !selectedService) {
82
278
  return [];
@@ -84,13 +280,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
84
280
  return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
85
281
  }, [selectedService, setup]);
86
282
  useEffect(() => {
283
+ // Wait for availableStaff to populate before clearing — otherwise
284
+ // a pre-seeded staffMemberId (e.g. from rescheduleContext) gets
285
+ // wiped on the first render before the staff list arrives.
286
+ if (availableStaff.length === 0)
287
+ return;
87
288
  if (selectedStaffId && !availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
88
289
  setSelectedStaffId(null);
89
290
  }
90
291
  }, [availableStaff, selectedStaffId]);
91
292
  const selectedStaff = useMemo(() => availableStaff.find((staffMember) => staffMember._id === selectedStaffId) ?? null, [availableStaff, selectedStaffId]);
92
293
  useEffect(() => {
93
- if (!selectedServiceId) {
294
+ if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
94
295
  setAvailabilityByDate(new Map());
95
296
  setSelectedDate(null);
96
297
  return;
@@ -98,7 +299,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
98
299
  let cancelled = false;
99
300
  const monthStart = formatDateKey(calendarMonth);
100
301
  const monthEnd = formatDateKey(endOfMonth(calendarMonth));
101
- const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
302
+ // Cache (and the underlying query) are NOT keyed on selectedStaffId
303
+ // anymore. The server returns aggregated slots tagged with
304
+ // `availableStaffIds`, and we filter client-side per selected
305
+ // provider. That means switching providers in the dropdown is
306
+ // instant (no refetch) and we always know which staff are
307
+ // available on a given date — even when a single provider is
308
+ // selected — so we can disable unavailable rows in the dropdown.
309
+ const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, calendarMonth);
102
310
  const cachedAvailability = availabilityCacheRef.current.get(requestKey);
103
311
  if (cachedAvailability) {
104
312
  setAvailabilityByDate(cachedAvailability);
@@ -116,7 +324,6 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
116
324
  .getPublicAvailableSlots({
117
325
  siteSlug,
118
326
  serviceId: selectedServiceId,
119
- staffMemberId: selectedStaffId ?? undefined,
120
327
  startDate: monthStart,
121
328
  endDate: monthEnd,
122
329
  })
@@ -143,7 +350,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
143
350
  if (!cancelled) {
144
351
  setAvailabilityByDate(new Map());
145
352
  setSelectedDate(null);
146
- setError(nextError instanceof Error ? nextError.message : 'Unable to load booking availability.');
353
+ setError(nextError instanceof Error ? nextError.message : t('calendarLoadError'));
147
354
  }
148
355
  })
149
356
  .finally(() => {
@@ -154,9 +361,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
154
361
  return () => {
155
362
  cancelled = true;
156
363
  };
157
- }, [calendarMonth, client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
364
+ }, [calendarMonth, client, selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
158
365
  useEffect(() => {
159
- if (!selectedServiceId) {
366
+ if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
160
367
  return;
161
368
  }
162
369
  const nextMonth = addMonths(calendarMonth, 1);
@@ -164,18 +371,71 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
164
371
  client,
165
372
  siteSlug,
166
373
  selectedServiceId,
167
- selectedStaffId,
168
374
  calendarMonth: nextMonth,
169
375
  cacheRef: availabilityCacheRef,
170
376
  });
171
- }, [calendarMonth, client, selectedServiceId, selectedStaffId, siteSlug]);
377
+ }, [calendarMonth, client, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
172
378
  useEffect(() => {
173
379
  setSelectedSlot(null);
174
380
  setPendingSlotKey(null);
175
381
  setHeldStaffId(null);
176
382
  setSuccess(null);
177
383
  setError(null);
178
- }, [selectedDate, selectedServiceId, selectedStaffId]);
384
+ setPayment(null);
385
+ setPaymentAppointmentId(null);
386
+ if (selectedService?.publicBookingMode === 'async') {
387
+ setViewState('details');
388
+ // Async services skip the date/time step (step 2). Drop the
389
+ // customer straight into the details form (step 3); the new
390
+ // 4-step model lets them review on step 4 before confirming.
391
+ setMobileStep(3);
392
+ }
393
+ else {
394
+ setViewState('slots');
395
+ }
396
+ }, [selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId]);
397
+ useEffect(() => {
398
+ setIntakeResponses({});
399
+ setQuote(null);
400
+ }, [selectedServiceId]);
401
+ useEffect(() => {
402
+ if (!selectedServiceId ||
403
+ selectedService?.pricingMode !== 'calculated' ||
404
+ !isIntakeComplete) {
405
+ setQuote(null);
406
+ setIsQuoteLoading(false);
407
+ return;
408
+ }
409
+ let cancelled = false;
410
+ setIsQuoteLoading(true);
411
+ const handle = window.setTimeout(() => {
412
+ void client
413
+ .quotePublicBooking({
414
+ siteSlug,
415
+ serviceId: selectedServiceId,
416
+ intakeResponses,
417
+ })
418
+ .then((nextQuote) => {
419
+ if (!cancelled) {
420
+ setQuote(nextQuote);
421
+ setError(null);
422
+ }
423
+ })
424
+ .catch((nextError) => {
425
+ if (!cancelled) {
426
+ setError(nextError instanceof Error ? nextError.message : 'Unable to calculate price.');
427
+ }
428
+ })
429
+ .finally(() => {
430
+ if (!cancelled)
431
+ setIsQuoteLoading(false);
432
+ });
433
+ }, 300);
434
+ return () => {
435
+ cancelled = true;
436
+ window.clearTimeout(handle);
437
+ };
438
+ }, [client, intakeResponses, isIntakeComplete, selectedService?.pricingMode, selectedServiceId, siteSlug]);
179
439
  useEffect(() => {
180
440
  return () => {
181
441
  if (!holdId) {
@@ -225,9 +485,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
225
485
  return;
226
486
  }
227
487
  if (holdExpiresAt <= Date.now()) {
228
- setHoldId(null);
229
- setHoldExpiresAt(null);
230
- setHeldStaffId(null);
488
+ handleHoldExpired();
231
489
  return;
232
490
  }
233
491
  const refreshAt = Math.max(1000, Math.min(60000, holdExpiresAt - Date.now() - 30000));
@@ -242,13 +500,39 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
242
500
  setHoldExpiresAt(result.expiresAt);
243
501
  })
244
502
  .catch(() => {
245
- setHoldId(null);
246
- setHoldExpiresAt(null);
247
- setHeldStaffId(null);
503
+ handleHoldExpired();
248
504
  });
249
505
  }, refreshAt);
250
506
  return () => window.clearTimeout(handle);
507
+ // handleHoldExpired is stable enough — it only reads setters
508
+ // eslint-disable-next-line react-hooks/exhaustive-deps
251
509
  }, [client, holdExpiresAt, holdId, sessionToken, siteSlug]);
510
+ // Fires the moment the displayed countdown hits zero. The refresh
511
+ // effect above usually preempts this by extending the hold ~30s
512
+ // early, so this is the last-line kick when the user has been idle
513
+ // long enough that even the refresh failed (or was never reached
514
+ // because the tab was backgrounded).
515
+ useEffect(() => {
516
+ if (holdExpiresAt === null)
517
+ return;
518
+ if (holdNow >= holdExpiresAt) {
519
+ handleHoldExpired();
520
+ }
521
+ // eslint-disable-next-line react-hooks/exhaustive-deps
522
+ }, [holdExpiresAt, holdNow]);
523
+ function handleHoldExpired() {
524
+ setHoldId(null);
525
+ setHoldExpiresAt(null);
526
+ setHeldStaffId(null);
527
+ setSelectedSlot(null);
528
+ setQuote(null);
529
+ setError(t('errorHoldExpired'));
530
+ setViewState('slots');
531
+ // Mobile flow: step 2 is the date+time picker. Async services
532
+ // (no slot) never see a hold, so this branch only fires for
533
+ // scheduled services that must go back to step 2.
534
+ setMobileStep((step) => (step > 2 ? 2 : step));
535
+ }
252
536
  useEffect(() => {
253
537
  if (!holdExpiresAt) {
254
538
  return;
@@ -259,16 +543,174 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
259
543
  }, 1000);
260
544
  return () => window.clearInterval(handle);
261
545
  }, [holdExpiresAt]);
262
- const selectedDateSlots = selectedDate ? availabilityByDate.get(selectedDate) ?? [] : [];
546
+ // The query is unfiltered by staff (so we always know who's
547
+ // available on each date for the dropdown disabled state). When a
548
+ // specific provider is selected, filter slots client-side to only
549
+ // those they can fulfill.
550
+ const filteredAvailabilityByDate = useMemo(() => {
551
+ if (!selectedStaffId)
552
+ return availabilityByDate;
553
+ const next = new Map();
554
+ for (const [date, slots] of availabilityByDate) {
555
+ const matching = slots.filter((slot) => slot.availableStaffIds?.includes(selectedStaffId));
556
+ if (matching.length > 0)
557
+ next.set(date, matching);
558
+ }
559
+ return next;
560
+ }, [availabilityByDate, selectedStaffId]);
561
+ // Set of staff IDs that have at least one slot on the selected
562
+ // date (regardless of who's currently filtered). Drives the
563
+ // "Unavailable" subtitle + disabled state in the provider dropdown.
564
+ const staffIdsAvailableOnSelectedDate = useMemo(() => {
565
+ if (!selectedDate)
566
+ return null;
567
+ const slots = availabilityByDate.get(selectedDate);
568
+ if (!slots || slots.length === 0)
569
+ return new Set();
570
+ const set = new Set();
571
+ for (const slot of slots) {
572
+ for (const id of slot.availableStaffIds ?? []) {
573
+ set.add(id);
574
+ }
575
+ }
576
+ return set;
577
+ }, [availabilityByDate, selectedDate]);
578
+ const selectedDateSlots = selectedDate ? filteredAvailabilityByDate.get(selectedDate) ?? [] : [];
263
579
  const selectedHeldStaff = availableStaff.find((staffMember) => staffMember._id === heldStaffId) ?? selectedStaff ?? null;
264
580
  const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
265
- const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
581
+ const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4, intlLocale), [calendarMonth, intlLocale]);
266
582
  const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
267
- const canAdvanceStep1 = true;
268
- const canAdvanceStep2 = Boolean(selectedService);
269
- const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
270
- const canSubmit = Boolean(selectedService && selectedDate && selectedSlot && holdId && customerName.trim() && customerEmail.trim()) &&
271
- !isSubmitting;
583
+ // Mobile 4-step gates. canAdvanceStepN = "the user can move PAST
584
+ // step N to step N+1". Step 1 (service) requires a pick. Step 2
585
+ // combines calendar + provider + time slots; user must pick date +
586
+ // slot to advance. Step 3 is the details form needs contact name,
587
+ // valid email, and any required intake fields filled. Step 4 is
588
+ // the review screen (submit, not advance).
589
+ const canAdvanceStep1 = Boolean(selectedService);
590
+ const canAdvanceStep2 = Boolean(selectedDate && selectedSlot);
591
+ const canAdvanceStep3 = Boolean(selectedService &&
592
+ customerName.trim() &&
593
+ isCustomerEmailValid &&
594
+ isIntakeComplete);
595
+ // Slots block is rendered in two locations: inline under the calendar
596
+ // (mobile flow keeps current step-3 behavior) and at the top of the
597
+ // right column on desktop (calendar | slots | form layout). Both
598
+ // locations share the same handlers and state, so picking a slot in
599
+ // either place updates the widget identically. CSS hides the wrong
600
+ // copy per breakpoint via .bw-slots-mobile / .bw-slots-desktop.
601
+ // Re-mount the slots wrapper whenever the inputs that affect the
602
+ // slot list change so the CSS enter animation fires (.bw-slots-fade
603
+ // + per-slot stagger). React diffs keys per parent, so reusing the
604
+ // same key on both the mobile and desktop render sites is fine —
605
+ // each parent independently remounts its child.
606
+ // Provider row is the visible-list replacement for the old
607
+ // dropdown — same data, same handlers, but always-rendered so the
608
+ // user sees every option at a glance (Any + each staff). Active
609
+ // selection is the filled state; unavailable staff (no slots on
610
+ // the selected date) render disabled with an inline "Unavailable"
611
+ // label. The row scrolls horizontally when content exceeds the
612
+ // available width, so it works on desktop AND mobile without a
613
+ // breakpoint-specific layout.
614
+ const providerRow = selectedService && !isEditingService ? (_jsxs("div", { className: "bw-staff-row", role: "listbox", "aria-label": t('chooseProviderAria'), children: [_jsxs("button", { type: "button", role: "option", "aria-selected": selectedStaffId === null, className: `bw-staff-card${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => setSelectedStaffId(null), children: [_jsx("span", { className: "bw-staff-card-avatar", children: _jsx(UserIcon, {}) }), _jsxs("span", { className: "bw-staff-card-info", children: [_jsx("span", { className: "bw-staff-card-name", children: t('providerAny') }), _jsx("span", { className: "bw-staff-card-desc", children: t('providerFirstAvailable') })] })] }), availableStaff
615
+ .filter((staffMember) => staffIdsAvailableOnSelectedDate === null ||
616
+ staffIdsAvailableOnSelectedDate.has(staffMember._id))
617
+ .map((staffMember) => {
618
+ const isActive = selectedStaffId === staffMember._id;
619
+ return (_jsxs("button", { type: "button", role: "option", "aria-selected": isActive, className: `bw-staff-card${isActive ? ' is-active' : ''}`, onClick: () => setSelectedStaffId(staffMember._id), children: [_jsx("span", { className: "bw-staff-card-avatar", children: staffMember.image?.url ? (_jsx("img", { src: staffMember.image.url, alt: staffMember.image.alt ?? staffMember.name, className: "bw-staff-avatar-img" })) : (_jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) })) }), _jsx("span", { className: "bw-staff-card-info", children: _jsx("span", { className: "bw-staff-card-name", children: staffMember.name }) })] }, staffMember._id));
620
+ })] })) : null;
621
+ const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
622
+ const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
623
+ const slotKey = `${slot.time}-${slot.endTime}`;
624
+ const isPending = pendingSlotKey === slotKey;
625
+ const isActive = isPending ||
626
+ (selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
627
+ return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, style: { '--bw-slot-i': index }, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? t('slotSecuring') : formatTimeLabel(slot.time, intlLocale) }, slotKey));
628
+ }) })) : (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') }))) : null }, slotsAreaKey));
629
+ const canSubmit = Boolean(selectedService &&
630
+ (!selectedServiceRequiresSlot || (selectedDate && selectedSlot && holdId)) &&
631
+ isIntakeComplete &&
632
+ (selectedService.pricingMode !== 'calculated' || quote) &&
633
+ customerName.trim() &&
634
+ isCustomerEmailValid) && !isSubmitting;
635
+ const submitBlockers = selectedService
636
+ ? getSubmitBlockers({
637
+ selectedService,
638
+ selectedServiceRequiresSlot,
639
+ selectedDate,
640
+ selectedSlot,
641
+ holdId,
642
+ isIntakeComplete,
643
+ intakeResponses,
644
+ selectedServicePath,
645
+ quote,
646
+ customerName,
647
+ customerEmail,
648
+ t,
649
+ locale,
650
+ })
651
+ : [];
652
+ function handleNotesInput(event) {
653
+ setCustomerNotes(event.currentTarget.value);
654
+ }
655
+ function renderNotesField(id, label, placeholder) {
656
+ return (_jsxs("div", { className: "bw-field bw-field--notes", children: [_jsx("label", { htmlFor: id, children: label }), _jsx("textarea", { id: id, rows: 3, maxLength: 500, value: customerNotes, onChange: handleNotesInput, onInput: handleNotesInput, placeholder: placeholder }), _jsxs("span", { className: "bw-char-count", "aria-live": "polite", children: [customerNotes.length, "/500"] })] }));
657
+ }
658
+ function renderSubmitHelp() {
659
+ if (!selectedService || canSubmit || payment || isSubmitting)
660
+ return null;
661
+ if (submitBlockers.length === 0 && !isQuoteLoading)
662
+ return null;
663
+ // Only surface the "complete required fields" callout AFTER the
664
+ // user has tried to confirm. The Confirm button stays visually
665
+ // disabled but still receives the click via handleConfirmAttempt
666
+ // so we can flip submitAttempted and reveal the blockers list.
667
+ if (!submitAttempted)
668
+ return null;
669
+ const visibleBlockers = submitBlockers.slice(0, 5);
670
+ const hiddenCount = submitBlockers.length - visibleBlockers.length;
671
+ return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isQuoteLoading ? t('helpCalculating') : t('helpComplete') }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? _jsx("li", { children: t('helpMoreFields', { count: hiddenCount }) }) : null] })) : null] }));
672
+ }
673
+ function renderContactFields(idSuffix) {
674
+ const nameId = `bw-name${idSuffix}`;
675
+ const emailId = `bw-email${idSuffix}`;
676
+ const phoneId = `bw-phone${idSuffix}`;
677
+ const emailErrorId = `${emailId}-error`;
678
+ return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: [t('contactFullName'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: t('placeholderFullName') })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: [t('contactEmail'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: t('placeholderEmail') }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: t('contactEmailInvalid') })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: t('contactPhone') }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: t('placeholderPhone') })] })] }));
679
+ }
680
+ function renderIntakeFields(idPrefix) {
681
+ return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
682
+ }
683
+ function renderIntakeAndContact(idPrefix, idSuffix) {
684
+ const intakeFields = renderIntakeFields(idPrefix);
685
+ const contactFields = renderContactFields(idSuffix);
686
+ // Contact first, intake after. Customers expect "tell us who you
687
+ // are" before "tell us about your shipment" — name and email are
688
+ // identity, intake fields are service-specific details.
689
+ return (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
690
+ }
691
+ function handleServiceSelect(service) {
692
+ if (holdId) {
693
+ void client
694
+ .releasePublicBookingHold({ siteSlug, holdId, sessionToken })
695
+ .catch(() => undefined);
696
+ }
697
+ setSelectedServiceId(service._id);
698
+ setIsEditingService(false);
699
+ setPayment(null);
700
+ setPaymentAppointmentId(null);
701
+ if (service.publicBookingMode === 'async') {
702
+ setSelectedDate(null);
703
+ setSelectedSlot(null);
704
+ setHoldId(null);
705
+ setHoldExpiresAt(null);
706
+ setHeldStaffId(null);
707
+ setViewState('details');
708
+ // Async = no date/time step; jump straight to the details form
709
+ // (step 3). The user advances to step 4 (review) via the Next
710
+ // button once the form is valid.
711
+ setMobileStep(3);
712
+ }
713
+ }
272
714
  async function handleSlotSelect(slot) {
273
715
  if (!selectedServiceId || !selectedDate) {
274
716
  return;
@@ -284,7 +726,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
284
726
  const reservationStaffId = selectedStaffId ?? slot.availableStaffIds[0] ?? null;
285
727
  if (!reservationStaffId) {
286
728
  setPendingSlotKey(null);
287
- setError('No staff is available for that time.');
729
+ setError(t('errorNoStaffForSlot'));
288
730
  return;
289
731
  }
290
732
  setSelectedSlot(slot);
@@ -313,6 +755,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
313
755
  setHoldExpiresAt(result.expiresAt);
314
756
  setHeldStaffId(reservationStaffId);
315
757
  setError(null);
758
+ // Auto-advance the desktop right pane to the details/form view
759
+ // once the hold is reserved. Mobile flow keeps the existing
760
+ // step-based navigation; this state change is harmless there
761
+ // because mobile CSS forces the details pane visible regardless.
762
+ setViewState('details');
316
763
  }
317
764
  catch (nextError) {
318
765
  setSelectedSlot(previousSlot);
@@ -320,32 +767,110 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
320
767
  setHoldExpiresAt(previousHoldExpiresAt);
321
768
  setHeldStaffId(previousHeldStaffId);
322
769
  setPendingSlotKey(null);
323
- setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
770
+ setError(nextError instanceof Error ? nextError.message : t('errorHoldFailed'));
771
+ }
772
+ }
773
+ async function handleBackToSlots() {
774
+ if (holdId) {
775
+ await client
776
+ .releasePublicBookingHold({ siteSlug, holdId, sessionToken })
777
+ .catch(() => undefined);
324
778
  }
779
+ setSelectedSlot(null);
780
+ setHoldId(null);
781
+ setHoldExpiresAt(null);
782
+ setHeldStaffId(null);
783
+ setPendingSlotKey(null);
784
+ setViewState('slots');
785
+ }
786
+ function handleBackToServicePicker() {
787
+ setSelectedServiceId(null);
788
+ setSelectedDate(null);
789
+ setSelectedSlot(null);
790
+ setHoldId(null);
791
+ setHoldExpiresAt(null);
792
+ setHeldStaffId(null);
793
+ setPendingSlotKey(null);
794
+ setIsEditingService(false);
795
+ setPayment(null);
796
+ setPaymentAppointmentId(null);
797
+ setViewState('slots');
798
+ setMobileStep(1);
799
+ }
800
+ function handleConfirmAttempt() {
801
+ // The disabled state is purely visual (aria-disabled + class). Real
802
+ // submittability gates here so we can surface validation help only
803
+ // after the user actually tries to confirm — never on a fresh form.
804
+ if (!canSubmit || isQuoteLoading || isSubmitting) {
805
+ setSubmitAttempted(true);
806
+ return;
807
+ }
808
+ void handleConfirmBooking();
325
809
  }
326
810
  async function handleConfirmBooking() {
327
- if (!selectedServiceId || !selectedService || !selectedDate || !selectedSlot || !holdId) {
811
+ if (!selectedServiceId ||
812
+ !selectedService ||
813
+ (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId))) {
328
814
  return;
329
815
  }
330
816
  setIsSubmitting(true);
331
817
  setError(null);
818
+ const newStartTime = selectedDate && selectedSlot
819
+ ? Date.parse(`${selectedDate}T${selectedSlot.time}:00`)
820
+ : Date.now();
821
+ const newEndTime = selectedDate && selectedSlot
822
+ ? Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`)
823
+ : newStartTime + selectedService.durationMinutes * 60 * 1000;
824
+ // Reschedule mode bypasses the public-create flow entirely.
825
+ // Service / staff / customer are already attached to the
826
+ // original appointment, so the host (admin path) or magic-link
827
+ // signed customer (public path) only needs to dispatch the new
828
+ // start/end. The hold is released as a courtesy since the slot
829
+ // is being committed via a different mutation that re-runs the
830
+ // conflict check itself.
831
+ if (isReschedule && onRescheduleSubmit) {
832
+ try {
833
+ await onRescheduleSubmit({ newStartTime, newEndTime });
834
+ setSuccess(t('successRescheduleConfirmed'));
835
+ setMobileStep(4);
836
+ }
837
+ catch (nextError) {
838
+ setError(nextError instanceof Error
839
+ ? nextError.message
840
+ : 'Unable to reschedule. Please try again.');
841
+ }
842
+ finally {
843
+ setIsSubmitting(false);
844
+ }
845
+ return;
846
+ }
332
847
  try {
333
- await client.createPublicBooking({
848
+ const created = await client.createPublicBooking({
334
849
  siteSlug,
335
850
  serviceId: selectedServiceId,
336
- staffMemberId: heldStaffId ?? selectedStaffId ?? selectedSlot.availableStaffIds[0],
337
- startTime: Date.parse(`${selectedDate}T${selectedSlot.time}:00`),
338
- endTime: Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`),
339
- timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? 'UTC',
851
+ staffMemberId: selectedServiceRequiresSlot
852
+ ? heldStaffId ?? selectedStaffId ?? selectedSlot?.availableStaffIds[0]
853
+ : undefined,
854
+ startTime: newStartTime,
855
+ endTime: newEndTime,
856
+ timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? setup?.workspaceTimezone ?? 'UTC',
340
857
  deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
341
858
  customerName: customerName.trim(),
342
859
  customerEmail: customerEmail.trim(),
343
860
  customerPhone: customerPhone.trim() || undefined,
344
861
  customerNotes: customerNotes.trim() || undefined,
345
- bookingHoldId: holdId,
346
- bookingSessionToken: sessionToken,
862
+ intakeResponses,
863
+ quotedTotalCents: quote?.totalCents,
864
+ bookingHoldId: selectedServiceRequiresSlot ? holdId : undefined,
865
+ bookingSessionToken: selectedServiceRequiresSlot ? sessionToken : undefined,
347
866
  });
348
- setSuccess('Booking confirmed.');
867
+ if (created.payment) {
868
+ setPayment(created.payment);
869
+ setPaymentAppointmentId(created.appointmentId);
870
+ setError(null);
871
+ return;
872
+ }
873
+ setSuccess(t('successBookingConfirmed'));
349
874
  setSelectedSlot(null);
350
875
  setPendingSlotKey(null);
351
876
  setHoldId(null);
@@ -355,164 +880,789 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
355
880
  setCustomerEmail('');
356
881
  setCustomerPhone('');
357
882
  setCustomerNotes('');
883
+ setIntakeResponses({});
884
+ setQuote(null);
885
+ setPayment(null);
886
+ setPaymentAppointmentId(null);
358
887
  setMobileStep(4);
359
888
  }
360
889
  catch (nextError) {
361
- setError(nextError instanceof Error ? nextError.message : 'Unable to confirm booking.');
890
+ setError(nextError instanceof Error ? nextError.message : t('errorConfirmFailed'));
362
891
  }
363
892
  finally {
364
893
  setIsSubmitting(false);
365
894
  }
366
895
  }
367
896
  if (isSetupLoading) {
368
- return (_jsx("section", { className: "bw", children: _jsx(BookingWidgetSkeleton, {}) }));
897
+ // Boneyard renders the bones JSON captured for "booking-widget"
898
+ // (see ./bones/booking-widget.bones.json). Children are an empty
899
+ // sentinel section since the bones own the layout during load.
900
+ return (_jsx(Skeleton, { name: "booking-widget", loading: true, children: _jsx("section", { className: "bw" }) }));
369
901
  }
370
902
  if (!setup || setup.services.length === 0) {
371
- return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
372
- }
373
- if (success) {
374
- 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: () => {
375
- setSuccess(null);
376
- setSelectedDate(findFirstAvailableDate(availabilityByDate, calendarMonth));
377
- }, children: "Book another visit" })] }) }));
378
- }
379
- 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: () => {
380
- setSelectedStaffId(null);
381
- setStaffOpen(false);
382
- }, 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: () => {
383
- setSelectedStaffId(staffMember._id);
384
- setStaffOpen(false);
385
- }, 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) => {
903
+ return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: t('errorNoServices') }) }));
904
+ }
905
+ if (forcedState === 'cancelled') {
906
+ return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon bw-done-icon--muted", children: _jsx(XIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: t('cancelledTitle') }), _jsx("p", { className: "bw-done-text", children: t('cancelledBody') }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
907
+ /* dev preview only — host wires the real handler */
908
+ }, children: t('cancelledCta') })] }) }));
909
+ }
910
+ if (success || forcedState === 'success') {
911
+ 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: isReschedule ? t('successTitleReschedule') : t('successTitleCreate') }), _jsx("p", { className: "bw-done-text", children: isReschedule ? t('successBodyReschedule') : t('successBodyCreate') }), isReschedule ? null : (_jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
912
+ window.location.assign(successRedirectHref);
913
+ }, children: t('successCtaBackHome') }))] }) }));
914
+ }
915
+ return (_jsx(Skeleton, { name: "booking-widget", loading: false, children: _jsxs("section", { className: "bw", "data-view-state": viewState, ref: widgetRootRef, 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 ||
916
+ (isReschedule
917
+ ? rescheduleContext?.customerName
918
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
919
+ : t('rescheduleTitle')
920
+ : t('pageTitle')) }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
921
+ ? rescheduleContext?.customerName
922
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
923
+ : t('rescheduleTitle')
924
+ : mobileStepTitle(mobileStep, t) }), mobileStep > 1 ? (_jsx("div", { className: "bw-progress", "aria-hidden": "true", children: (selectedServiceRequiresSlot
925
+ ? MOBILE_PROGRESS_STEPS_SCHEDULED
926
+ : MOBILE_PROGRESS_STEPS_ASYNC).map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })) : null] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsxs("div", { className: "bw-content", children: [_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("div", { className: "bw-service-picker", children: selectedService && !isEditingService ? (_jsxs("div", { className: "bw-svc-selected", children: [_jsxs("div", { className: "bw-svc-selected-header", children: [_jsx("label", { className: "bw-label", children: t('selectedService') }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: t('btnChange') })] }), _jsxs("div", { className: "bw-svc-row bw-svc-row--readonly", children: [selectedService.image ? (_jsxs("span", { className: "bw-svc-image is-checked", children: [_jsx("img", { src: selectedService.image.url, alt: selectedService.image.alt ?? pickLocaleField(selectedService, 'name', locale) ?? selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), pickLocaleField(selectedService, 'description', locale) ?? selectedService.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService) })] })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { className: "bw-label", children: t('selectService') }), _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: serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
927
+ const isActive = selectedServiceId === service._id;
928
+ return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
929
+ void prefetchAvailability({
930
+ client,
931
+ siteSlug,
932
+ selectedServiceId: service._id,
933
+ calendarMonth,
934
+ cacheRef: availabilityCacheRef,
935
+ });
936
+ }, onFocus: () => {
937
+ void prefetchAvailability({
938
+ client,
939
+ siteSlug,
940
+ selectedServiceId: service._id,
941
+ calendarMonth,
942
+ cacheRef: availabilityCacheRef,
943
+ });
944
+ }, onClick: () => {
945
+ handleServiceSelect(service);
946
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? pickLocaleField(service, 'name', locale) ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_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: pickLocaleField(service, 'name', locale) ?? service.name }), pickLocaleField(service, 'description', locale) ?? service.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(service, 'description', locale) ?? service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service, undefined, intlLocale) })] })] })] }, service._id));
947
+ })] }, group.key))) }) }) })] })) }), selectedService && selectedServiceRequiresSlot && !isEditingService ? (_jsxs("div", { className: "bw-provider-section", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: t('chooseYourService') }), _jsx("div", { className: "bw-step-2-divider" }), serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
386
948
  const isActive = selectedServiceId === service._id;
387
- return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
388
- void prefetchAvailability({
389
- client,
390
- siteSlug,
391
- selectedServiceId: service._id,
392
- selectedStaffId,
393
- calendarMonth,
394
- cacheRef: availabilityCacheRef,
395
- });
396
- }, onFocus: () => {
397
- void prefetchAvailability({
398
- client,
399
- siteSlug,
400
- selectedServiceId: service._id,
401
- selectedStaffId,
402
- calendarMonth,
403
- cacheRef: availabilityCacheRef,
404
- });
405
- }, onClick: () => {
406
- setSelectedServiceId(service._id);
407
- setMobileStep(2);
408
- }, 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));
409
- }) }) }) })] })] }), _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) => {
410
- const isActive = selectedServiceId === service._id;
411
- return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
412
- void prefetchAvailability({
413
- client,
414
- siteSlug,
415
- selectedServiceId: service._id,
416
- selectedStaffId,
417
- calendarMonth,
418
- cacheRef: availabilityCacheRef,
419
- });
420
- }, onFocus: () => {
421
- void prefetchAvailability({
422
- client,
423
- siteSlug,
424
- selectedServiceId: service._id,
425
- selectedStaffId,
426
- calendarMonth,
427
- cacheRef: availabilityCacheRef,
428
- });
429
- }, 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));
430
- })] })] }), _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: () => {
431
- setCalendarMonth(option.date);
432
- setMonthOpen(false);
433
- }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
434
- if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
435
- void prefetchAvailability({
436
- client,
437
- siteSlug,
438
- selectedServiceId,
439
- selectedStaffId,
440
- calendarMonth: addMonths(calendarMonth, -1),
441
- cacheRef: availabilityCacheRef,
442
- });
443
- }
444
- }, 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: () => {
445
- if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
446
- void prefetchAvailability({
447
- client,
448
- siteSlug,
449
- selectedServiceId,
450
- selectedStaffId,
451
- calendarMonth: addMonths(calendarMonth, 1),
452
- cacheRef: availabilityCacheRef,
453
- });
454
- }
455
- }, 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) => {
456
- const dateKey = formatDateKey(day);
457
- const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
458
- const isPast = dateKey < formatDateKey(startOfDay(new Date()));
459
- const slots = availabilityByDate.get(dateKey) ?? [];
460
- const isAvailable = slots.length > 0;
461
- const isSelected = selectedDate === dateKey;
462
- const className = [
463
- 'bw-cal-day',
464
- !isCurrentMonth ? 'is-outside' : '',
465
- isSelected ? 'is-selected' : '',
466
- isAvailable && !isPast ? 'is-available' : '',
467
- isPast ? 'is-disabled' : '',
468
- !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
469
- dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
470
- ]
471
- .filter(Boolean)
472
- .join(' ');
473
- return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
474
- setSelectedDate(dateKey);
475
- setSelectedSlot(null);
476
- setMobileStep((current) => (current < 3 ? 3 : current));
477
- }, children: day.getDate() }, dateKey));
478
- }) }), !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) => {
479
- const slotKey = `${slot.time}-${slot.endTime}`;
480
- const isPending = pendingSlotKey === slotKey;
481
- const isActive = isPending ||
482
- (selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
483
- 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));
484
- }) })] })) : (_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) ||
949
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
950
+ void prefetchAvailability({
951
+ client,
952
+ siteSlug,
953
+ selectedServiceId: service._id,
954
+ calendarMonth,
955
+ cacheRef: availabilityCacheRef,
956
+ });
957
+ }, onFocus: () => {
958
+ void prefetchAvailability({
959
+ client,
960
+ siteSlug,
961
+ selectedServiceId: service._id,
962
+ calendarMonth,
963
+ cacheRef: availabilityCacheRef,
964
+ });
965
+ }, onClick: () => {
966
+ handleServiceSelect(service);
967
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image bw-svc-image--lg${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? pickLocaleField(service, 'name', locale) ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_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: pickLocaleField(service, 'name', locale) ?? service.name }), pickLocaleField(service, 'description', locale) ?? service.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(service, 'description', locale) ?? service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service, undefined, intlLocale) })] })] })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
968
+ })] }, group.key)))] })] }), _jsxs("div", { className: "bw-col bw-col--center", children: [_jsxs("div", { className: "bw-cal-card", ref: calCardRef, 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, intlLocale) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": t('closeMonthPicker'), 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: () => {
969
+ setCalendarMonth(option.date);
970
+ setMonthOpen(false);
971
+ }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
972
+ if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
973
+ void prefetchAvailability({
974
+ client,
975
+ siteSlug,
976
+ selectedServiceId,
977
+ calendarMonth: addMonths(calendarMonth, -1),
978
+ cacheRef: availabilityCacheRef,
979
+ });
980
+ }
981
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": t('prevMonth'), children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
982
+ if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
983
+ void prefetchAvailability({
984
+ client,
985
+ siteSlug,
986
+ selectedServiceId,
987
+ calendarMonth: addMonths(calendarMonth, 1),
988
+ cacheRef: availabilityCacheRef,
989
+ });
990
+ }
991
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": t('nextMonth'), children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: [
992
+ t('weekdaySun'),
993
+ t('weekdayMon'),
994
+ t('weekdayTue'),
995
+ t('weekdayWed'),
996
+ t('weekdayThu'),
997
+ t('weekdayFri'),
998
+ t('weekdaySat'),
999
+ ].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
1000
+ const dateKey = formatDateKey(day);
1001
+ const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
1002
+ const isPast = dateKey < formatDateKey(startOfDay(new Date()));
1003
+ const slots = filteredAvailabilityByDate.get(dateKey) ?? [];
1004
+ const isAvailable = slots.length > 0;
1005
+ const isSelected = selectedDate === dateKey;
1006
+ const className = [
1007
+ 'bw-cal-day',
1008
+ !isCurrentMonth ? 'is-outside' : '',
1009
+ isSelected ? 'is-selected' : '',
1010
+ isAvailable && !isPast ? 'is-available' : '',
1011
+ isPast ? 'is-disabled' : '',
1012
+ !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
1013
+ dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
1014
+ ]
1015
+ .filter(Boolean)
1016
+ .join(' ');
1017
+ return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
1018
+ setSelectedDate(dateKey);
1019
+ setSelectedSlot(null);
1020
+ setMobileStep((current) => (current < 2 ? 2 : current));
1021
+ }, children: day.getDate() }, dateKey));
1022
+ }) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: t('calendarSelectServicePrompt') })) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] })] }), selectedService && !isEditingService ? (_jsxs("div", { className: "bw-mobile-card bw-provider-section--mobile", children: [_jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null, selectedService && selectedDate ? (_jsxs("div", { className: "bw-mobile-card bw-slots-mobile", children: [_jsx("label", { className: "bw-label", children: t('selectTimePrompt') }), slotsArea] })) : null] }), _jsx("div", { className: "bw-col bw-col--review", "aria-hidden": mobileStep !== 4, children: selectedService ? (_jsxs("div", { className: "bw-summary bw-summary--review", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? t('summaryTitleReschedule') : t('summaryTitleCreate') }), holdSecondsRemaining !== null ? (_jsx("div", { className: "bw-summary-rows", children: _jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] }) })) : null, _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewBookingDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerService') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerStaff') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] })] })] }), (() => {
1023
+ const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
1024
+ if (intakeRows.length === 0)
1025
+ return null;
1026
+ return (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewAdditionalInfo') }), _jsx("div", { className: "bw-summary-rows", children: intakeRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.multiline ? ' bw-summary-row--stack' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] }));
1027
+ })(), customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule ? t('notesLabelReschedule') : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote, intlLocale) })] }) })] })) : null }), _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-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: t('slotsHeading') }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? t('summaryTitleReschedule') : t('summaryTitleCreate') }), _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: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: {
1028
+ ...summaryValStyle,
1029
+ textDecoration: 'line-through',
1030
+ opacity: 0.55,
1031
+ }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerService') }), _jsx("span", { className: "bw-summary-val", style: {
1032
+ ...summaryValStyle,
1033
+ textDecoration: 'line-through',
1034
+ opacity: 0.55,
1035
+ }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerStaff') }), _jsx("span", { className: "bw-summary-val", style: {
1036
+ ...summaryValStyle,
1037
+ textDecoration: 'line-through',
1038
+ opacity: 0.55,
1039
+ }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote, intlLocale) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
1040
+ setSuccess(t('successPaymentReceived'));
1041
+ setPayment(null);
1042
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
1043
+ ? isReschedule
1044
+ ? t('btnRescheduling')
1045
+ : t('btnBooking')
1046
+ : isReschedule
1047
+ ? t('btnConfirmReschedule')
1048
+ : selectedServiceRequiresPayment
1049
+ ? t('btnContinueToPayment')
1050
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-details-view", children: [_jsx("div", { className: "bw-details-summary", children: selectedService ? (_jsxs(_Fragment, { children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsx("h3", { className: "bw-details-service", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), pickLocaleField(selectedService, 'description', locale) ?? selectedService.description ? (_jsx("p", { className: "bw-details-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryFormerTime') }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })] })) : null, selectedDate && selectedSlot ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isReschedule ? t('summaryNewTime') : t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}), formatTimeLabel(selectedSlot.time, intlLocale), " \u2013 ", formatTimeLabel(selectedSlot.endTime, intlLocale)] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, quote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule ? t('notesPlaceholderReschedule') : t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment && (forcedState === 'payment-full' || forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
1051
+ setSuccess(t('successPaymentReceived'));
1052
+ setPayment(null);
1053
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
1054
+ ? isReschedule
1055
+ ? t('btnRescheduling')
1056
+ : t('btnBooking')
1057
+ : forcedState === 'payment-full'
1058
+ ? t('btnPayAndConfirm', { price: formatPrice(selectedService, intlLocale) })
1059
+ : forcedState === 'payment-deposit'
1060
+ ? t('btnPayDepositAndConfirm')
1061
+ : isReschedule
1062
+ ? t('btnConfirmReschedule')
1063
+ : selectedServiceRequiresPayment
1064
+ ? t('btnContinueToPayment')
1065
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] })] }), _jsx("div", { className: "bw-footer", children: _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => {
1066
+ setMobileStep((step) => {
1067
+ // Async services skip step 2 going backward too.
1068
+ // Step 3 → step 1 instead of step 3 → step 2.
1069
+ if (step === 3 && !selectedServiceRequiresSlot)
1070
+ return 1;
1071
+ return Math.max(1, step - 1);
1072
+ });
1073
+ }, children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
485
1074
  (mobileStep === 2 && !canAdvanceStep2) ||
486
- (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] }))] })] })] }));
1075
+ (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => {
1076
+ // Async services skip step 2 (no date/time picker).
1077
+ // Step 1 → step 3 → step 4.
1078
+ if (step === 1 && !selectedServiceRequiresSlot)
1079
+ return 3;
1080
+ return Math.min(4, step + 1);
1081
+ }), children: [_jsx("span", { children: t('btnNext') }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: `bw-footer-next${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
1082
+ ? isReschedule
1083
+ ? t('btnRescheduling')
1084
+ : t('btnBooking')
1085
+ : isReschedule
1086
+ ? t('btnConfirmReschedule')
1087
+ : selectedServiceRequiresPayment
1088
+ ? t('btnContinueToPayment')
1089
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] }, bookingFlowKey) }));
1090
+ }
1091
+ /* Payment panel — design proposal for the pay-before-booking flow.
1092
+ * Renders inside the desktop details form (and mobile step-4 form
1093
+ * once wired) when the selected service has paymentMode === 'full'
1094
+ * or 'deposit'. Visuals only today: real implementation will mount
1095
+ * a Stripe PaymentElement inside .bw-pay-card-slot and gate the
1096
+ * Confirm CTA on a successful charge. Deposit mode shows the split
1097
+ * (charged today vs at-visit) so the customer sees what's actually
1098
+ * coming out of their card now.
1099
+ *
1100
+ * TODO(eric): Eric is adding a Stripe widget to the LEFT side of
1101
+ * the details view (alongside the summary, not the form). When you
1102
+ * pick this up:
1103
+ * 1. Move/replace this PaymentPanel into the left summary pane
1104
+ * (.bw-details-summary) so the Stripe Element sits where the
1105
+ * customer reads what they're paying for. The right form pane
1106
+ * stays as-is — name/email/phone/notes only.
1107
+ * 2. Backend gap: getPublicBookingSetup currently strips the
1108
+ * payment fields. Surface paymentMode, depositCents,
1109
+ * requiresPayment, and the workspace's Stripe Connect account
1110
+ * handle (so the PaymentElement can be configured against the
1111
+ * right tenant) in the public response + the
1112
+ * PublicBookingSetup type.
1113
+ * 3. Mount Elements provider scoped to the widget; create a
1114
+ * PaymentIntent on hold success (server-side mutation pinned
1115
+ * to the bookingHoldId so the amount can't be tampered with).
1116
+ * 4. Gate createPublicAppointment behind paymentIntent.status
1117
+ * === 'succeeded'. On failure: keep the hold for one retry.
1118
+ * 5. Confirm CTA copy already branches on forcedState — wire the
1119
+ * same branch on the real `service.requiresPayment` field.
1120
+ * 6. Visual reference: this PaymentPanel + the dev preview pills
1121
+ * ('Pay full' / 'Pay deposit') in dev/booking-widget-preview
1122
+ * show the design intent for the summary card layout. Reuse
1123
+ * the .bw-pay-summary chrome on the left side; the
1124
+ * .bw-pay-card-slot is what gets replaced by <PaymentElement />.
1125
+ */
1126
+ function PaymentPanel({ service, mode, }) {
1127
+ const { t, locale } = useTranslation();
1128
+ if (!service)
1129
+ return null;
1130
+ const formatter = currencyFormatter(service.currency, localeToIntl(locale));
1131
+ const total = service.priceCents / 100;
1132
+ // Deposit defaults to 30% of total when no explicit depositCents
1133
+ // is set on the service. Real flow reads `service.depositCents`.
1134
+ const depositToday = mode === 'full' ? total : Math.round(total * 0.3 * 100) / 100;
1135
+ const dueAtVisit = mode === 'full' ? 0 : Math.round((total - depositToday) * 100) / 100;
1136
+ return (_jsxs("div", { className: "bw-pay", children: [_jsxs("div", { className: "bw-pay-summary", children: [_jsx("div", { className: "bw-pay-summary-header", children: t('paymentHeader') }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: pickLocaleField(service, 'name', locale) ?? service.name }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: t('paymentSubtotal') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: t('paymentTax') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(0) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--total", children: [_jsx("span", { children: t('paymentTotal') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: t('paymentChargedToday') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(depositToday) })] }), mode === 'deposit' ? (_jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: t('paymentDueAtVisit') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(dueAtVisit) })] })) : null] }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: t('paymentCardDetails') }), _jsx("div", { className: "bw-pay-card-slot", children: _jsxs("div", { className: "bw-pay-card-stub", children: [_jsxs("div", { className: "bw-pay-card-stub-row", children: [_jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentCardNumber') }), _jsx("span", { className: "bw-pay-card-stub-value", children: "1234 1234 1234 1234" })] }), _jsxs("div", { className: "bw-pay-card-stub-grid", children: [_jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentExpiry') }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentCvc') }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentZip') }) })] })] }) }), _jsxs("p", { className: "bw-pay-secure", children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDD12" }), " ", t('paymentSecureNote')] })] })] }));
1137
+ }
1138
+ function IntakeFormFields({ form, responses, onChange, idPrefix, mode, }) {
1139
+ const { locale } = useTranslation();
1140
+ if (!form || form.sections.length === 0)
1141
+ return null;
1142
+ return (_jsx("div", { className: "bw-intake", children: form.sections
1143
+ .filter((section) => !section.showWhenPath || section.showWhenPath === mode)
1144
+ .map((section) => (_jsxs("div", { className: "bw-intake-section", children: [_jsx("div", { className: "bw-intake-title", children: pickLocaleField(section, 'title', locale) ?? section.id }), _jsx("div", { className: "bw-form-fields", children: section.fields.map((field) => (_jsx(IntakeField, { field: field, value: responses[field.id], onChange: (value) => onChange(field.id, value), idPrefix: `${idPrefix}-${section.id}` }, field.id))) })] }, section.id))) }));
1145
+ }
1146
+ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1147
+ return form.sections
1148
+ .filter((section) => !section.showWhenPath || section.showWhenPath === mode)
1149
+ .every((section) => section.fields.every((field) => {
1150
+ const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1151
+ ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1152
+ : undefined;
1153
+ return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1154
+ }));
1155
+ }
1156
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
1157
+ const blockers = [];
1158
+ if (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId)) {
1159
+ blockers.push(t('blockerDateTime'));
1160
+ }
1161
+ if (!customerName.trim())
1162
+ blockers.push(t('blockerContactName'));
1163
+ if (!customerEmail.trim()) {
1164
+ blockers.push(t('blockerContactEmail'));
1165
+ }
1166
+ else if (!isValidEmailAddress(customerEmail)) {
1167
+ blockers.push(t('blockerContactEmailInvalid'));
1168
+ }
1169
+ if (!isIntakeComplete && selectedService.intakeForm) {
1170
+ blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1171
+ }
1172
+ if (selectedService.pricingMode === 'calculated' &&
1173
+ isIntakeComplete &&
1174
+ !quote) {
1175
+ blockers.push(t('blockerCalculatedPrice'));
1176
+ }
1177
+ return Array.from(new Set(blockers));
1178
+ }
1179
+ function isValidEmailAddress(value) {
1180
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
1181
+ }
1182
+ function getMissingRequiredIntakeLabels(form, responses, mode, t, locale = DEFAULT_LOCALE) {
1183
+ const missing = [];
1184
+ for (const section of form.sections) {
1185
+ if (section.showWhenPath && section.showWhenPath !== mode)
1186
+ continue;
1187
+ const sectionLabel = getIntakeSectionLabel(section, locale);
1188
+ for (const field of section.fields) {
1189
+ const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1190
+ ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1191
+ : undefined;
1192
+ collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, t, locale, minItems);
1193
+ }
1194
+ }
1195
+ return missing;
1196
+ }
1197
+ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, t, locale, minItems) {
1198
+ if (!field.required)
1199
+ return;
1200
+ if (field.type !== 'repeatable-group') {
1201
+ if (!isRequiredIntakeFieldComplete(field, value)) {
1202
+ missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field, locale)}`);
1203
+ }
1204
+ return;
1205
+ }
1206
+ const fieldLabel = getIntakeFieldLabel(field, locale);
1207
+ const items = Array.isArray(value) ? value : [];
1208
+ const requiredItemCount = Math.max(minItems ?? 1, items.length || 1);
1209
+ if (items.length < requiredItemCount) {
1210
+ missing.push(`${sectionLabel}: ${fieldLabel}`);
1211
+ }
1212
+ for (let index = 0; index < requiredItemCount; index += 1) {
1213
+ const item = items[index];
1214
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1215
+ ? item
1216
+ : {};
1217
+ for (const child of field.fields ?? []) {
1218
+ if (!child.required)
1219
+ continue;
1220
+ if (!isRequiredIntakeFieldComplete(child, record[child.id])) {
1221
+ missing.push(`${sectionLabel}: ${t('repeatItemLabel', { index: index + 1 })} ${getIntakeFieldLabel(child, locale)}`);
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+ function getIntakeSectionLabel(section, locale = DEFAULT_LOCALE) {
1227
+ return pickLocaleField(section, 'title', locale) ?? section.id;
1228
+ }
1229
+ function getIntakeFieldLabel(field, locale = DEFAULT_LOCALE) {
1230
+ return pickLocaleField(field, 'label', locale) ?? field.id;
1231
+ }
1232
+ function isRequiredIntakeFieldComplete(field, value, minItems) {
1233
+ if (!field.required)
1234
+ return true;
1235
+ if (field.type === 'checkbox') {
1236
+ return value === true;
1237
+ }
1238
+ if (field.type === 'repeatable-group') {
1239
+ if (!Array.isArray(value) || value.length < (minItems ?? 1))
1240
+ return false;
1241
+ return value.every((item) => {
1242
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1243
+ ? item
1244
+ : {};
1245
+ return (field.fields ?? []).every((child) => isRequiredIntakeFieldComplete(child, record[child.id]));
1246
+ });
1247
+ }
1248
+ if (field.type === 'number') {
1249
+ if (typeof value === 'number')
1250
+ return Number.isFinite(value);
1251
+ if (typeof value === 'string')
1252
+ return value.trim().length > 0 && Number.isFinite(Number(value));
1253
+ return false;
1254
+ }
1255
+ return typeof value === 'string' && value.trim().length > 0;
1256
+ }
1257
+ function formatIntakeValue(field, value, locale, t) {
1258
+ if (field.type === 'checkbox') {
1259
+ return value === true ? t('reviewYes') : t('reviewNo');
1260
+ }
1261
+ if (field.type === 'select') {
1262
+ const option = (field.options ?? []).find((opt) => opt.value === value);
1263
+ if (option) {
1264
+ return (pickLocaleField(option, 'label', locale) ??
1265
+ option.label ??
1266
+ String(value));
1267
+ }
1268
+ }
1269
+ if (typeof value === 'string') {
1270
+ const trimmed = value.trim();
1271
+ return trimmed.length > 0 ? trimmed : t('reviewNotProvided');
1272
+ }
1273
+ if (typeof value === 'number')
1274
+ return String(value);
1275
+ return t('reviewNotProvided');
1276
+ }
1277
+ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1278
+ if (!form)
1279
+ return [];
1280
+ const rows = [];
1281
+ for (const section of form.sections) {
1282
+ if (section.showWhenPath && section.showWhenPath !== servicePath)
1283
+ continue;
1284
+ for (const field of section.fields) {
1285
+ if (field.type === 'repeatable-group') {
1286
+ const items = Array.isArray(responses[field.id]) ? responses[field.id] : [];
1287
+ items.forEach((item, index) => {
1288
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1289
+ ? item
1290
+ : {};
1291
+ const itemLabel = t('repeatItemLabel', { index: index + 1 });
1292
+ (field.fields ?? []).forEach((child) => {
1293
+ rows.push({
1294
+ key: `${field.id}-${index}-${child.id}`,
1295
+ label: `${itemLabel} · ${getIntakeFieldLabel(child, locale)}`,
1296
+ value: formatIntakeValue(child, record[child.id], locale, t),
1297
+ });
1298
+ });
1299
+ });
1300
+ continue;
1301
+ }
1302
+ const isMultiline = field.type === 'textarea';
1303
+ rows.push({
1304
+ key: field.id,
1305
+ label: getIntakeFieldLabel(field, locale),
1306
+ value: formatIntakeValue(field, responses[field.id], locale, t),
1307
+ multiline: isMultiline,
1308
+ });
1309
+ }
1310
+ }
1311
+ return rows;
1312
+ }
1313
+ function readFiniteNumber(value, fallback) {
1314
+ if (typeof value === 'number' && Number.isFinite(value))
1315
+ return value;
1316
+ if (typeof value === 'string' && value.trim()) {
1317
+ const parsed = Number(value);
1318
+ if (Number.isFinite(parsed))
1319
+ return parsed;
1320
+ }
1321
+ return fallback;
1322
+ }
1323
+ /**
1324
+ * Custom select that replaces the native <select>. The native control
1325
+ * renders differently on every OS/browser (macOS dark-mode picker,
1326
+ * Windows Chrome OS picker, etc.) and breaks the widget's visual
1327
+ * language. This implementation matches the .bw-field input chrome,
1328
+ * is fully keyboard-navigable (arrows, Home/End, Enter, Space, Escape,
1329
+ * Tab), respects prefers-reduced-motion, and uses the WAI-ARIA
1330
+ * combobox + listbox pattern for screen reader support.
1331
+ */
1332
+ function IntakeSelect({ id, value, onChange, options, placeholder, required, }) {
1333
+ const [isOpen, setIsOpen] = useState(false);
1334
+ const [focusedIndex, setFocusedIndex] = useState(-1);
1335
+ const [placement, setPlacement] = useState('below');
1336
+ const rootRef = useRef(null);
1337
+ const triggerRef = useRef(null);
1338
+ const listRef = useRef(null);
1339
+ const listboxId = `${id}-listbox`;
1340
+ /**
1341
+ * Decide which side to open the menu on. If the space below the
1342
+ * trigger isn't enough to fit the menu, and there's more space
1343
+ * above, flip the menu above the trigger. Runs at every open
1344
+ * (desktop AND mobile) — handles both the desktop near-footer
1345
+ * case and the mobile near-bottom-of-widget case.
1346
+ *
1347
+ * The menu's CSS max-height is `min(280px, 60svh)`. We compute
1348
+ * the same effective max here so the flip decision tracks the
1349
+ * real available space — important on short phones where 60svh
1350
+ * is much smaller than 280px.
1351
+ */
1352
+ function computePlacement() {
1353
+ if (typeof window === 'undefined')
1354
+ return 'below';
1355
+ const triggerEl = triggerRef.current;
1356
+ if (!triggerEl)
1357
+ return 'below';
1358
+ const rect = triggerEl.getBoundingClientRect();
1359
+ const effectiveMenuHeight = Math.min(280, window.innerHeight * 0.6);
1360
+ const padding = 16;
1361
+ const spaceBelow = window.innerHeight - rect.bottom - padding;
1362
+ const spaceAbove = rect.top - padding;
1363
+ if (spaceBelow < effectiveMenuHeight && spaceAbove > spaceBelow) {
1364
+ return 'above';
1365
+ }
1366
+ return 'below';
1367
+ }
1368
+ const currentIndex = options.findIndex((option) => option.value === value);
1369
+ const currentLabel = currentIndex >= 0 ? options[currentIndex].label : placeholder;
1370
+ function open(initialIndex) {
1371
+ if (isOpen)
1372
+ return;
1373
+ setPlacement(computePlacement());
1374
+ setIsOpen(true);
1375
+ const start = initialIndex !== undefined
1376
+ ? initialIndex
1377
+ : currentIndex >= 0
1378
+ ? currentIndex
1379
+ : 0;
1380
+ setFocusedIndex(Math.max(0, Math.min(options.length - 1, start)));
1381
+ }
1382
+ function close({ refocus = true } = {}) {
1383
+ setIsOpen(false);
1384
+ setFocusedIndex(-1);
1385
+ if (refocus && triggerRef.current)
1386
+ triggerRef.current.focus();
1387
+ }
1388
+ function selectAt(index) {
1389
+ const option = options[index];
1390
+ if (!option)
1391
+ return;
1392
+ onChange(option.value);
1393
+ close();
1394
+ }
1395
+ // Outside-click close. Listens on mousedown so the click on the
1396
+ // trigger itself doesn't immediately fire close + reopen.
1397
+ useEffect(() => {
1398
+ if (!isOpen)
1399
+ return;
1400
+ function handleDown(event) {
1401
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
1402
+ close({ refocus: false });
1403
+ }
1404
+ }
1405
+ document.addEventListener('mousedown', handleDown);
1406
+ return () => document.removeEventListener('mousedown', handleDown);
1407
+ }, [isOpen]);
1408
+ // Keep the keyboard-focused option in view as the user arrows
1409
+ // through. block: 'nearest' avoids unnecessary scroll jumps.
1410
+ useEffect(() => {
1411
+ if (!isOpen || focusedIndex < 0 || !listRef.current)
1412
+ return;
1413
+ const optionEl = listRef.current.querySelector(`[data-option-index="${focusedIndex}"]`);
1414
+ optionEl?.scrollIntoView({ block: 'nearest' });
1415
+ }, [focusedIndex, isOpen]);
1416
+ function handleTriggerKeyDown(event) {
1417
+ switch (event.key) {
1418
+ case 'ArrowDown':
1419
+ case 'ArrowUp':
1420
+ case 'Enter':
1421
+ case ' ':
1422
+ event.preventDefault();
1423
+ open(event.key === 'ArrowUp' ? options.length - 1 : undefined);
1424
+ break;
1425
+ case 'Home':
1426
+ event.preventDefault();
1427
+ open(0);
1428
+ break;
1429
+ case 'End':
1430
+ event.preventDefault();
1431
+ open(options.length - 1);
1432
+ break;
1433
+ }
1434
+ }
1435
+ function handleListKeyDown(event) {
1436
+ switch (event.key) {
1437
+ case 'ArrowDown':
1438
+ event.preventDefault();
1439
+ setFocusedIndex((current) => current >= options.length - 1 ? 0 : current + 1);
1440
+ break;
1441
+ case 'ArrowUp':
1442
+ event.preventDefault();
1443
+ setFocusedIndex((current) => current <= 0 ? options.length - 1 : current - 1);
1444
+ break;
1445
+ case 'Home':
1446
+ event.preventDefault();
1447
+ setFocusedIndex(0);
1448
+ break;
1449
+ case 'End':
1450
+ event.preventDefault();
1451
+ setFocusedIndex(options.length - 1);
1452
+ break;
1453
+ case 'Enter':
1454
+ case ' ':
1455
+ event.preventDefault();
1456
+ if (focusedIndex >= 0)
1457
+ selectAt(focusedIndex);
1458
+ break;
1459
+ case 'Escape':
1460
+ event.preventDefault();
1461
+ close();
1462
+ break;
1463
+ case 'Tab':
1464
+ // Close + let the browser move focus naturally.
1465
+ close({ refocus: false });
1466
+ break;
1467
+ }
1468
+ }
1469
+ const hasValue = currentIndex >= 0;
1470
+ return (_jsxs("div", { className: "bw-select", ref: rootRef, children: [_jsxs("button", { ref: triggerRef, type: "button", id: id, className: `bw-select-trigger${isOpen ? ' is-open' : ''}${hasValue ? ' has-value' : ''}`, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-required": required ? true : undefined, onClick: () => (isOpen ? close({ refocus: false }) : open()), onKeyDown: handleTriggerKeyDown, children: [_jsx("span", { className: `bw-select-value${hasValue ? '' : ' is-placeholder'}`, children: currentLabel }), _jsx("span", { className: "bw-select-chevron", "aria-hidden": "true", children: _jsx(ChevronDownIcon, {}) })] }), isOpen ? (_jsx("ul", { id: listboxId, role: "listbox", tabIndex: -1, "aria-activedescendant": focusedIndex >= 0 ? `${id}-option-${focusedIndex}` : undefined, className: `bw-select-menu${placement === 'above' ? ' is-above' : ''}`, onKeyDown: handleListKeyDown,
1471
+ // Prevent the trigger from losing focus on click; we
1472
+ // manage focus ourselves via close({ refocus: true }).
1473
+ onMouseDown: (event) => event.preventDefault(), ref: (node) => {
1474
+ listRef.current = node;
1475
+ if (node)
1476
+ node.focus();
1477
+ }, children: options.map((option, index) => {
1478
+ const isSelected = option.value === value;
1479
+ const isFocused = index === focusedIndex;
1480
+ return (_jsxs("li", { id: `${id}-option-${index}`, "data-option-index": index, role: "option", "aria-selected": isSelected, className: `bw-select-option${isSelected ? ' is-active' : ''}${isFocused ? ' is-focused' : ''}`, onMouseEnter: () => setFocusedIndex(index), onClick: () => selectAt(index), children: [_jsx("span", { className: "bw-select-option-label", children: option.label }), isSelected ? (_jsx("span", { className: "bw-select-option-check", "aria-hidden": "true", children: _jsx(CheckIcon, {}) })) : null] }, option.value));
1481
+ }) })) : null] }));
1482
+ }
1483
+ function IntakeField({ field, value, onChange, idPrefix, }) {
1484
+ const { t, locale } = useTranslation();
1485
+ const id = `${idPrefix}-${field.id}`;
1486
+ const label = pickLocaleField(field, 'label', locale) ?? field.id;
1487
+ const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
1488
+ if (field.type === 'repeatable-group') {
1489
+ const items = Array.isArray(value) && value.length > 0 ? value : [{}];
1490
+ return (_jsxs("div", { className: "bw-field bw-field--wide bw-repeatable", children: [_jsx("label", { children: label }), items.map((item, index) => {
1491
+ const record = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
1492
+ return (_jsxs("div", { className: "bw-repeatable-item", children: [_jsxs("div", { className: "bw-repeatable-head", children: [_jsx("span", { children: t('repeatItemLabel', { index: index + 1 }) }), items.length > 1 ? (_jsx("button", { type: "button", className: "bw-link-btn", onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)), children: t('repeatRemove') })) : null] }), _jsx("div", { className: "bw-form-fields", children: (field.fields ?? []).map((child) => (_jsx(IntakeField, { field: child, value: record[child.id], idPrefix: `${id}-${index}`, onChange: (nextValue) => {
1493
+ const nextItems = [...items];
1494
+ nextItems[index] = { ...record, [child.id]: nextValue };
1495
+ onChange(nextItems);
1496
+ } }, child.id))) })] }, index));
1497
+ }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: t('repeatAdd') })] }));
1498
+ }
1499
+ if (field.type === 'checkbox') {
1500
+ return (_jsxs("div", { className: "bw-field bw-field--wide bw-checkbox-field", children: [_jsxs("label", { htmlFor: id, children: [_jsx("input", { id: id, type: "checkbox", checked: value === true, onChange: (event) => onChange(event.target.checked) }), _jsxs("span", { children: [label, field.required ? ' *' : ''] })] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1501
+ }
1502
+ if (field.type === 'select') {
1503
+ const options = (field.options ?? []).map((option) => ({
1504
+ value: option.value,
1505
+ label: (pickLocaleField(option, 'label', locale) ??
1506
+ option.labelEn ??
1507
+ option.label) ||
1508
+ option.value,
1509
+ }));
1510
+ return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx(IntakeSelect, { id: id, value: stringValue, onChange: onChange, options: options, placeholder: t('selectFieldPlaceholder'), required: field.required }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1511
+ }
1512
+ if (field.type === 'textarea') {
1513
+ return (_jsxs("div", { className: "bw-field bw-field--wide", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx("textarea", { id: id, rows: 3, required: field.required, value: stringValue, placeholder: field.placeholder, onChange: (event) => onChange(event.target.value) }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1514
+ }
1515
+ return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx("input", { id: id, type: field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : 'text', min: field.min, max: field.max, required: field.required, value: stringValue, placeholder: field.placeholder, onChange: (event) => {
1516
+ onChange(field.type === 'number'
1517
+ ? event.target.value === ''
1518
+ ? ''
1519
+ : Number(event.target.value)
1520
+ : event.target.value);
1521
+ } }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
487
1522
  }
488
- function BookingWidgetSkeleton() {
489
- 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" })] })] }));
1523
+ function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1524
+ const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
1525
+ return (_jsx(Elements, { stripe: stripePromise, options: {
1526
+ clientSecret: payment.clientSecret,
1527
+ }, children: _jsx(StripePaymentForm, { amountCents: payment.amountCents, currency: payment.currency, appointmentId: appointmentId, showInlineButton: showInlineButton, actionTarget: actionTarget, onSuccess: onSuccess, onError: onError }) }));
490
1528
  }
1529
+ function StripePaymentForm({ amountCents, currency, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1530
+ const { t, locale } = useTranslation();
1531
+ const stripe = useStripe();
1532
+ const elements = useElements();
1533
+ const [isPaying, setIsPaying] = useState(false);
1534
+ async function handlePay() {
1535
+ if (!stripe || !elements)
1536
+ return;
1537
+ setIsPaying(true);
1538
+ const result = await stripe.confirmPayment({
1539
+ elements,
1540
+ confirmParams: {
1541
+ return_url: `${window.location.origin}${window.location.pathname}?appointment_id=${appointmentId ?? ''}`,
1542
+ },
1543
+ redirect: 'if_required',
1544
+ });
1545
+ setIsPaying(false);
1546
+ if (result.error) {
1547
+ onError(result.error.message ?? 'Payment failed. Please try again.');
1548
+ return;
1549
+ }
1550
+ onSuccess();
1551
+ }
1552
+ const renderPayButton = () => (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !stripe || !elements || isPaying, onClick: () => void handlePay(), children: [_jsx("span", { children: isPaying ? t('paymentProcessing') : t('paymentPayAndConfirm') }), !isPaying ? _jsx(ArrowRightIcon, {}) : null] }));
1553
+ return (_jsxs("div", { className: "bw-pay", children: [_jsx("div", { className: "bw-pay-summary", children: _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: t('paymentTotalDueToday') }), _jsx("span", { className: "bw-pay-summary-val", children: currencyFormatter(currency, localeToIntl(locale)).format(amountCents / 100) })] }) }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: t('paymentSecureLabel') }), _jsx("div", { className: "bw-pay-card-slot", children: _jsx(PaymentElement, {}) })] }), showInlineButton ? renderPayButton() : null, actionTarget ? createPortal(renderPayButton(), actionTarget) : null] }));
1554
+ }
1555
+ function ServiceWarningCallout({ warning }) {
1556
+ return (_jsxs("div", { className: "bw-service-warning", role: "alert", children: [_jsx("div", { className: "bw-service-warning-icon", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }), _jsxs("div", { className: "bw-service-warning-copy", children: [_jsx("div", { className: "bw-service-warning-title", children: warning.title }), _jsx("p", { children: warning.body }), warning.linkHref && warning.linkLabel ? (_jsx("a", { href: warning.linkHref, className: "bw-service-warning-link", children: warning.linkLabel })) : null] })] }));
1557
+ }
1558
+ function formatPrice(service, locale = 'en-US') {
1559
+ if (!service)
1560
+ return '';
1561
+ return formatServicePrice(service, undefined, locale);
1562
+ }
1563
+ function formatServicePrice(service, quote, locale = 'en-US') {
1564
+ if (service.pricingMode === 'calculated') {
1565
+ if (quote) {
1566
+ return currencyFormatter(quote.currency, locale).format(quote.totalCents / 100);
1567
+ }
1568
+ return 'Price calculated after details';
1569
+ }
1570
+ return currencyFormatter(service.currency, locale).format(service.priceCents / 100);
1571
+ }
1572
+ function getServiceWarning(service, mode, locale = DEFAULT_LOCALE) {
1573
+ if (!service || mode !== 'async')
1574
+ return null;
1575
+ const warning = readRecord(service.publicCopy?.remoteWarning);
1576
+ if (!warning)
1577
+ return null;
1578
+ const title = pickLocaleField(warning, 'title', locale);
1579
+ const body = pickLocaleField(warning, 'body', locale);
1580
+ if (!title || !body)
1581
+ return null;
1582
+ return {
1583
+ title,
1584
+ body,
1585
+ linkHref: readString(warning.linkHref) ?? undefined,
1586
+ linkLabel: readString(warning.linkLabelEs) ?? readString(warning.linkLabel) ?? undefined,
1587
+ };
1588
+ }
1589
+ function readRecord(value) {
1590
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1591
+ return null;
1592
+ return value;
1593
+ }
1594
+ function readString(value) {
1595
+ return typeof value === 'string' && value.trim() ? value : null;
1596
+ }
1597
+ // BookingWidgetSkeleton was a hand-built first-load ghost that
1598
+ // constantly drifted out of sync with the real layout. Replaced by
1599
+ // boneyard-js: BookingWidgetPanel wraps its main render in
1600
+ // <Skeleton name="booking-widget"> and `./bones/registry` ships
1601
+ // pre-captured bones JSON regenerated via `npx boneyard-js build`.
491
1602
  function AvailabilitySkeleton() {
492
- 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))) }) }));
1603
+ // Reuses .bw-time-slots so the skeleton inherits the same layout
1604
+ // overrides as the real slots — vertical 1-col on desktop (via the
1605
+ // .bw-slots-desktop scope), 4-col grid on mobile (default). Pill
1606
+ // dimensions match the rendered .bw-slot.
1607
+ return (_jsx("div", { className: "bw-time-slots bw-time-slots--skeleton", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }));
493
1608
  }
494
- function mobileStepTitle(step) {
1609
+ /**
1610
+ * Compact "Wed, May 6 · 3:00 PM – 5:00 PM" label used in reschedule
1611
+ * summary's "Former time" row. Uses local timezone of the browser;
1612
+ * the backend stores absolute ms timestamps so this renders correctly
1613
+ * for the host's calendar.
1614
+ */
1615
+ function formatFormerSlot(startMs, endMs, locale = 'en-US') {
1616
+ const start = new Date(startMs);
1617
+ const end = new Date(endMs);
1618
+ const dateLabel = start.toLocaleDateString(locale, {
1619
+ weekday: 'short',
1620
+ month: 'short',
1621
+ day: 'numeric',
1622
+ });
1623
+ const timeOpts = {
1624
+ hour: 'numeric',
1625
+ minute: '2-digit',
1626
+ };
1627
+ return `${dateLabel} · ${start.toLocaleTimeString(locale, timeOpts)} – ${end.toLocaleTimeString(locale, timeOpts)}`;
1628
+ }
1629
+ /**
1630
+ * Possessive form of a name. "Eric Lan" → "Eric Lan's", "James" →
1631
+ * "James'". Splits on the full string's last char so single-name
1632
+ * customers (and last-name-only handles, etc.) still resolve cleanly.
1633
+ */
1634
+ function formatPossessive(name) {
1635
+ const trimmed = name.trim();
1636
+ if (!trimmed)
1637
+ return trimmed;
1638
+ const last = trimmed[trimmed.length - 1];
1639
+ if (last === 's' || last === 'S')
1640
+ return `${trimmed}'`;
1641
+ return `${trimmed}'s`;
1642
+ }
1643
+ function mobileStepTitle(step, t) {
495
1644
  switch (step) {
496
1645
  case 1:
497
- return 'Schedule your visit';
1646
+ return t('stepChooseService');
498
1647
  case 2:
499
- return 'Select your service';
1648
+ return t('stepPickDateTime');
500
1649
  case 3:
501
- return 'Pick a date & time';
1650
+ return t('stepYourDetails');
502
1651
  case 4:
503
- return 'Your details';
1652
+ return t('stepReviewConfirm');
504
1653
  default:
505
- return 'Booking';
1654
+ return t('stepBooking');
506
1655
  }
507
1656
  }
508
1657
  function formatDuration(minutes) {
509
1658
  if (minutes >= 60 && minutes % 60 === 0) {
510
- return `${minutes / 60} hr`;
1659
+ const hours = minutes / 60;
1660
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
511
1661
  }
512
1662
  return `${minutes} min`;
513
1663
  }
514
- function currencyFormatter(currency) {
515
- return new Intl.NumberFormat('en-US', {
1664
+ function currencyFormatter(currency, locale = 'en-US') {
1665
+ return new Intl.NumberFormat(locale, {
516
1666
  style: 'currency',
517
1667
  currency: currency || 'USD',
518
1668
  minimumFractionDigits: 0,
@@ -545,26 +1695,28 @@ function formatDateKey(date) {
545
1695
  const day = `${date.getDate()}`.padStart(2, '0');
546
1696
  return `${year}-${month}-${day}`;
547
1697
  }
548
- function formatMonthLabel(date) {
549
- return new Intl.DateTimeFormat('en-US', {
550
- month: 'long',
551
- year: 'numeric',
552
- }).format(date);
1698
+ function formatMonthLabel(date, locale = 'en-US') {
1699
+ // Use a simple "Month YYYY" shape across locales. Default Spanish
1700
+ // formatting yields "mayo de 2026" (lowercased, with "de"), which
1701
+ // we don't want — strip both for a tighter calendar header.
1702
+ const month = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date);
1703
+ const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
1704
+ return `${capitalizedMonth} ${date.getFullYear()}`;
553
1705
  }
554
- function formatReadableDate(dateKey) {
555
- return new Intl.DateTimeFormat('en-US', {
1706
+ function formatReadableDate(dateKey, locale = 'en-US') {
1707
+ return new Intl.DateTimeFormat(locale, {
556
1708
  weekday: 'short',
557
1709
  month: 'short',
558
1710
  day: 'numeric',
559
1711
  }).format(new Date(`${dateKey}T00:00:00`));
560
1712
  }
561
- function formatTimeLabel(time) {
1713
+ function formatTimeLabel(time, locale = 'en-US') {
562
1714
  const [hourString, minuteString] = time.split(':');
563
1715
  const hour = Number(hourString);
564
1716
  const minute = Number(minuteString);
565
1717
  const date = new Date();
566
1718
  date.setHours(hour, minute, 0, 0);
567
- return new Intl.DateTimeFormat('en-US', {
1719
+ return new Intl.DateTimeFormat(locale, {
568
1720
  hour: 'numeric',
569
1721
  minute: '2-digit',
570
1722
  }).format(date);
@@ -594,13 +1746,13 @@ function buildCalendarDays(month) {
594
1746
  function sameMonth(left, right) {
595
1747
  return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
596
1748
  }
597
- function getMonthOptions(currentMonth, total) {
1749
+ function getMonthOptions(currentMonth, total, locale = 'en-US') {
598
1750
  const start = startOfMonth(new Date());
599
1751
  return Array.from({ length: total }, (_, index) => {
600
1752
  const date = addMonths(start, index);
601
1753
  return {
602
1754
  value: optionCurrentValue(date),
603
- label: formatMonthLabel(date),
1755
+ label: formatMonthLabel(date, locale),
604
1756
  date,
605
1757
  isCurrent: sameMonth(date, currentMonth),
606
1758
  };
@@ -620,15 +1772,14 @@ function findFirstAvailableDate(availabilityByDate, month) {
620
1772
  .sort();
621
1773
  return dates[0] ?? null;
622
1774
  }
623
- async function prefetchAvailability({ client, siteSlug, selectedServiceId, selectedStaffId, calendarMonth, cacheRef, }) {
624
- const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
1775
+ async function prefetchAvailability({ client, siteSlug, selectedServiceId, calendarMonth, cacheRef, }) {
1776
+ const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, calendarMonth);
625
1777
  if (cacheRef.current.has(requestKey)) {
626
1778
  return;
627
1779
  }
628
1780
  const result = await client.getPublicAvailableSlots({
629
1781
  siteSlug,
630
1782
  serviceId: selectedServiceId,
631
- staffMemberId: selectedStaffId ?? undefined,
632
1783
  startDate: formatDateKey(calendarMonth),
633
1784
  endDate: formatDateKey(endOfMonth(calendarMonth)),
634
1785
  });
@@ -638,8 +1789,8 @@ async function prefetchAvailability({ client, siteSlug, selectedServiceId, selec
638
1789
  }
639
1790
  cacheRef.current.set(requestKey, nextMap);
640
1791
  }
641
- function getAvailabilityCacheKey(siteSlug, serviceId, staffId, month) {
642
- return `${siteSlug}:${serviceId}:${staffId ?? 'any'}:${optionCurrentValue(month)}`;
1792
+ function getAvailabilityCacheKey(siteSlug, serviceId, month) {
1793
+ return `${siteSlug}:${serviceId}:${optionCurrentValue(month)}`;
643
1794
  }
644
1795
  function formatHoldCountdown(totalSeconds) {
645
1796
  const minutes = Math.floor(totalSeconds / 60);
@@ -667,10 +1818,22 @@ function ArrowRightIcon() {
667
1818
  function CheckIcon() {
668
1819
  return iconPath('M20 6 9 17l-5-5');
669
1820
  }
1821
+ function XIcon() {
1822
+ return iconPath('M18 6 6 18M6 6l12 12');
1823
+ }
1824
+ function WarningIcon() {
1825
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" }), _jsx("path", { d: "M12 9v4" }), _jsx("path", { d: "M12 17h.01" })] }));
1826
+ }
670
1827
  function UserIcon() {
671
1828
  return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
672
1829
  }
673
1830
  function GlobeIcon() {
674
1831
  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');
675
1832
  }
1833
+ function CalendarIcon() {
1834
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "4", width: "18", height: "18", rx: "2" }), _jsx("path", { d: "M16 2v4M8 2v4M3 10h18" })] }));
1835
+ }
1836
+ function ClockIcon() {
1837
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("path", { d: "M12 6v6l4 2" })] }));
1838
+ }
676
1839
  //# sourceMappingURL=booking-widget.js.map