@asksable/site-connector 0.2.0 → 0.3.1

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.
@@ -3,7 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { loadStripe } from '@stripe/stripe-js';
5
5
  import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
6
- import { useSableSiteClient, useSableSiteConfig } from './provider.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';
7
13
  const stripePromises = new Map();
8
14
  function getStripePromise(publishableKey, connectAccountId) {
9
15
  const key = `${publishableKey}:${connectAccountId}`;
@@ -24,7 +30,8 @@ function hasIntakeFormSections(service) {
24
30
  function findCalculatedServiceMissingIntake(setup) {
25
31
  return setup.services.find((service) => service.pricingMode === 'calculated' && !hasIntakeFormSections(service));
26
32
  }
27
- const MOBILE_PROGRESS_STEPS = [1, 2, 3, 4];
33
+ const MOBILE_PROGRESS_STEPS_SCHEDULED = [1, 2, 3, 4];
34
+ const MOBILE_PROGRESS_STEPS_ASYNC = [1, 3, 4];
28
35
  export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
29
36
  const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
30
37
  const isReschedule = mode === 'reschedule';
@@ -43,6 +50,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
43
50
  : undefined;
44
51
  const client = useSableSiteClient();
45
52
  const { siteSlug } = useSableSiteConfig();
53
+ const { t, locale } = useTranslation();
54
+ const intlLocale = localeToIntl(locale);
46
55
  const [setup, setSetup] = useState(null);
47
56
  const [isSetupLoading, setIsSetupLoading] = useState(true);
48
57
  // In reschedule mode the service is locked to the original
@@ -80,6 +89,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
80
89
  const [payment, setPayment] = useState(null);
81
90
  const [paymentAppointmentId, setPaymentAppointmentId] = useState(null);
82
91
  const [isSubmitting, setIsSubmitting] = useState(false);
92
+ const [submitAttempted, setSubmitAttempted] = useState(false);
83
93
  const [error, setError] = useState(null);
84
94
  const [success, setSuccess] = useState(null);
85
95
  const [mobileStep, setMobileStep] = useState(1);
@@ -98,10 +108,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
98
108
  }
99
109
  }, [__devForceState]);
100
110
  const [monthOpen, setMonthOpen] = useState(false);
101
- const [staffOpen, setStaffOpen] = useState(false);
102
111
  const [holdNow, setHoldNow] = useState(() => Date.now());
103
112
  const [sessionToken] = useState(() => sessionTokenFromBrowser());
104
- 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);
105
117
  const availabilityCacheRef = useRef(new Map());
106
118
  const holdIdRef = useRef(null);
107
119
  // Calendar card lives in the middle column and is the natural
@@ -130,6 +142,19 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
130
142
  useEffect(() => {
131
143
  holdIdRef.current = holdId;
132
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]);
133
158
  useEffect(() => {
134
159
  let cancelled = false;
135
160
  setIsSetupLoading(true);
@@ -163,26 +188,13 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
163
188
  cancelled = true;
164
189
  };
165
190
  }, [client, siteSlug]);
166
- useEffect(() => {
167
- function handleDocumentClick(event) {
168
- if (!staffMenuRef.current?.contains(event.target)) {
169
- setStaffOpen(false);
170
- }
171
- }
172
- if (staffOpen) {
173
- document.addEventListener('mousedown', handleDocumentClick);
174
- }
175
- return () => {
176
- document.removeEventListener('mousedown', handleDocumentClick);
177
- };
178
- }, [staffOpen]);
179
191
  const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
180
192
  const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
181
193
  const selectedServicePath = selectedServiceRequiresSlot ? 'scheduled' : 'async';
182
194
  const selectedServiceHasIntakeForm = hasIntakeFormSections(selectedService);
183
195
  const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
184
196
  areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
185
- const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath), [selectedService, selectedServicePath]);
197
+ const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath, locale), [selectedService, selectedServicePath, locale]);
186
198
  const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
187
199
  const trimmedCustomerEmail = customerEmail.trim();
188
200
  const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
@@ -287,7 +299,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
287
299
  let cancelled = false;
288
300
  const monthStart = formatDateKey(calendarMonth);
289
301
  const monthEnd = formatDateKey(endOfMonth(calendarMonth));
290
- 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);
291
310
  const cachedAvailability = availabilityCacheRef.current.get(requestKey);
292
311
  if (cachedAvailability) {
293
312
  setAvailabilityByDate(cachedAvailability);
@@ -305,7 +324,6 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
305
324
  .getPublicAvailableSlots({
306
325
  siteSlug,
307
326
  serviceId: selectedServiceId,
308
- staffMemberId: selectedStaffId ?? undefined,
309
327
  startDate: monthStart,
310
328
  endDate: monthEnd,
311
329
  })
@@ -332,7 +350,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
332
350
  if (!cancelled) {
333
351
  setAvailabilityByDate(new Map());
334
352
  setSelectedDate(null);
335
- setError(nextError instanceof Error ? nextError.message : 'Unable to load booking availability.');
353
+ setError(nextError instanceof Error ? nextError.message : t('calendarLoadError'));
336
354
  }
337
355
  })
338
356
  .finally(() => {
@@ -353,7 +371,6 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
353
371
  client,
354
372
  siteSlug,
355
373
  selectedServiceId,
356
- selectedStaffId,
357
374
  calendarMonth: nextMonth,
358
375
  cacheRef: availabilityCacheRef,
359
376
  });
@@ -368,7 +385,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
368
385
  setPaymentAppointmentId(null);
369
386
  if (selectedService?.publicBookingMode === 'async') {
370
387
  setViewState('details');
371
- setMobileStep(4);
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);
372
392
  }
373
393
  else {
374
394
  setViewState('slots');
@@ -465,9 +485,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
465
485
  return;
466
486
  }
467
487
  if (holdExpiresAt <= Date.now()) {
468
- setHoldId(null);
469
- setHoldExpiresAt(null);
470
- setHeldStaffId(null);
488
+ handleHoldExpired();
471
489
  return;
472
490
  }
473
491
  const refreshAt = Math.max(1000, Math.min(60000, holdExpiresAt - Date.now() - 30000));
@@ -482,13 +500,39 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
482
500
  setHoldExpiresAt(result.expiresAt);
483
501
  })
484
502
  .catch(() => {
485
- setHoldId(null);
486
- setHoldExpiresAt(null);
487
- setHeldStaffId(null);
503
+ handleHoldExpired();
488
504
  });
489
505
  }, refreshAt);
490
506
  return () => window.clearTimeout(handle);
507
+ // handleHoldExpired is stable enough — it only reads setters
508
+ // eslint-disable-next-line react-hooks/exhaustive-deps
491
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
+ }
492
536
  useEffect(() => {
493
537
  if (!holdExpiresAt) {
494
538
  return;
@@ -499,18 +543,55 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
499
543
  }, 1000);
500
544
  return () => window.clearInterval(handle);
501
545
  }, [holdExpiresAt]);
502
- 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) ?? [] : [];
503
579
  const selectedHeldStaff = availableStaff.find((staffMember) => staffMember._id === heldStaffId) ?? selectedStaff ?? null;
504
580
  const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
505
- const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
581
+ const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4, intlLocale), [calendarMonth, intlLocale]);
506
582
  const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
507
583
  // Mobile 4-step gates. canAdvanceStepN = "the user can move PAST
508
584
  // step N to step N+1". Step 1 (service) requires a pick. Step 2
509
- // (provider) is always passable since "Any provider" is the
510
- // default. Step 3 (calendar) requires both date + slot.
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).
511
589
  const canAdvanceStep1 = Boolean(selectedService);
512
- const canAdvanceStep2 = true;
513
- const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
590
+ const canAdvanceStep2 = Boolean(selectedDate && selectedSlot);
591
+ const canAdvanceStep3 = Boolean(selectedService &&
592
+ customerName.trim() &&
593
+ isCustomerEmailValid &&
594
+ isIntakeComplete);
514
595
  // Slots block is rendered in two locations: inline under the calendar
515
596
  // (mobile flow keeps current step-3 behavior) and at the top of the
516
597
  // right column on desktop (calendar | slots | form layout). Both
@@ -522,14 +603,28 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
522
603
  // + per-slot stagger). React diffs keys per parent, so reusing the
523
604
  // same key on both the mobile and desktop render sites is fine —
524
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.map((staffMember) => {
615
+ const isActive = selectedStaffId === staffMember._id;
616
+ const isAvailable = staffIdsAvailableOnSelectedDate === null ||
617
+ staffIdsAvailableOnSelectedDate.has(staffMember._id);
618
+ return (_jsxs("button", { type: "button", role: "option", "aria-selected": isActive, "aria-disabled": !isAvailable, disabled: !isAvailable, className: `bw-staff-card${isActive ? ' is-active' : ''}${!isAvailable ? ' is-disabled' : ''}`, 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) })) }), _jsxs("span", { className: "bw-staff-card-info", children: [_jsx("span", { className: "bw-staff-card-name", children: staffMember.name }), !isAvailable ? (_jsx("span", { className: "bw-staff-card-desc", children: t('providerUnavailable') })) : null] })] }, staffMember._id));
619
+ })] })) : null;
525
620
  const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
526
- const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: "Please select a service." })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
621
+ 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) => {
527
622
  const slotKey = `${slot.time}-${slot.endTime}`;
528
623
  const isPending = pendingSlotKey === slotKey;
529
624
  const isActive = isPending ||
530
625
  (selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
531
- 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 ? 'Securing...' : formatTimeLabel(slot.time) }, slotKey));
532
- }) })) : (_jsx("p", { className: "bw-no-slots", children: "No available times for this date." }))) : null }, slotsAreaKey));
626
+ 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));
627
+ }) })) : (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') }))) : null }, slotsAreaKey));
533
628
  const canSubmit = Boolean(selectedService &&
534
629
  (!selectedServiceRequiresSlot || (selectedDate && selectedSlot && holdId)) &&
535
630
  isIntakeComplete &&
@@ -549,6 +644,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
549
644
  quote,
550
645
  customerName,
551
646
  customerEmail,
647
+ t,
648
+ locale,
552
649
  })
553
650
  : [];
554
651
  function handleNotesInput(event) {
@@ -562,16 +659,22 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
562
659
  return null;
563
660
  if (submitBlockers.length === 0 && !isQuoteLoading)
564
661
  return null;
662
+ // Only surface the "complete required fields" callout AFTER the
663
+ // user has tried to confirm. The Confirm button stays visually
664
+ // disabled but still receives the click via handleConfirmAttempt
665
+ // so we can flip submitAttempted and reveal the blockers list.
666
+ if (!submitAttempted)
667
+ return null;
565
668
  const visibleBlockers = submitBlockers.slice(0, 5);
566
669
  const hiddenCount = submitBlockers.length - visibleBlockers.length;
567
- return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isQuoteLoading ? 'Calculating your price...' : 'Complete required fields to continue:' }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? _jsxs("li", { children: [hiddenCount, " more required fields"] }) : null] })) : null] }));
670
+ 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] }));
568
671
  }
569
672
  function renderContactFields(idSuffix) {
570
673
  const nameId = `bw-name${idSuffix}`;
571
674
  const emailId = `bw-email${idSuffix}`;
572
675
  const phoneId = `bw-phone${idSuffix}`;
573
676
  const emailErrorId = `${emailId}-error`;
574
- return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: ["Full name ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "John Doe" })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: ["Email ", _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: "jane@example.com" }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: "Enter a valid email address." })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: "Phone" }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "+1 (555) 000-0000" })] })] }));
677
+ 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') })] })] }));
575
678
  }
576
679
  function renderIntakeFields(idPrefix) {
577
680
  return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
@@ -579,7 +682,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
579
682
  function renderIntakeAndContact(idPrefix, idSuffix) {
580
683
  const intakeFields = renderIntakeFields(idPrefix);
581
684
  const contactFields = renderContactFields(idSuffix);
582
- return selectedServiceHasIntakeForm ? (_jsxs(_Fragment, { children: [intakeFields, contactFields] })) : (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
685
+ // Contact first, intake after. Customers expect "tell us who you
686
+ // are" before "tell us about your shipment" — name and email are
687
+ // identity, intake fields are service-specific details.
688
+ return (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
583
689
  }
584
690
  function handleServiceSelect(service) {
585
691
  if (holdId) {
@@ -598,7 +704,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
598
704
  setHoldExpiresAt(null);
599
705
  setHeldStaffId(null);
600
706
  setViewState('details');
601
- setMobileStep(4);
707
+ // Async = no date/time step; jump straight to the details form
708
+ // (step 3). The user advances to step 4 (review) via the Next
709
+ // button once the form is valid.
710
+ setMobileStep(3);
602
711
  }
603
712
  }
604
713
  async function handleSlotSelect(slot) {
@@ -616,7 +725,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
616
725
  const reservationStaffId = selectedStaffId ?? slot.availableStaffIds[0] ?? null;
617
726
  if (!reservationStaffId) {
618
727
  setPendingSlotKey(null);
619
- setError('No staff is available for that time.');
728
+ setError(t('errorNoStaffForSlot'));
620
729
  return;
621
730
  }
622
731
  setSelectedSlot(slot);
@@ -657,7 +766,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
657
766
  setHoldExpiresAt(previousHoldExpiresAt);
658
767
  setHeldStaffId(previousHeldStaffId);
659
768
  setPendingSlotKey(null);
660
- setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
769
+ setError(nextError instanceof Error ? nextError.message : t('errorHoldFailed'));
661
770
  }
662
771
  }
663
772
  async function handleBackToSlots() {
@@ -687,6 +796,16 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
687
796
  setViewState('slots');
688
797
  setMobileStep(1);
689
798
  }
799
+ function handleConfirmAttempt() {
800
+ // The disabled state is purely visual (aria-disabled + class). Real
801
+ // submittability gates here so we can surface validation help only
802
+ // after the user actually tries to confirm — never on a fresh form.
803
+ if (!canSubmit || isQuoteLoading || isSubmitting) {
804
+ setSubmitAttempted(true);
805
+ return;
806
+ }
807
+ void handleConfirmBooking();
808
+ }
690
809
  async function handleConfirmBooking() {
691
810
  if (!selectedServiceId ||
692
811
  !selectedService ||
@@ -711,7 +830,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
711
830
  if (isReschedule && onRescheduleSubmit) {
712
831
  try {
713
832
  await onRescheduleSubmit({ newStartTime, newEndTime });
714
- setSuccess('Reschedule confirmed.');
833
+ setSuccess(t('successRescheduleConfirmed'));
715
834
  setMobileStep(4);
716
835
  }
717
836
  catch (nextError) {
@@ -750,7 +869,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
750
869
  setError(null);
751
870
  return;
752
871
  }
753
- setSuccess('Booking confirmed.');
872
+ setSuccess(t('successBookingConfirmed'));
754
873
  setSelectedSlot(null);
755
874
  setPendingSlotKey(null);
756
875
  setHoldId(null);
@@ -767,209 +886,206 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
767
886
  setMobileStep(4);
768
887
  }
769
888
  catch (nextError) {
770
- setError(nextError instanceof Error ? nextError.message : 'Unable to confirm booking.');
889
+ setError(nextError instanceof Error ? nextError.message : t('errorConfirmFailed'));
771
890
  }
772
891
  finally {
773
892
  setIsSubmitting(false);
774
893
  }
775
894
  }
776
895
  if (isSetupLoading) {
777
- return (_jsx("section", { className: "bw", children: _jsx(BookingWidgetSkeleton, {}) }));
896
+ // Boneyard renders the bones JSON captured for "booking-widget"
897
+ // (see ./bones/booking-widget.bones.json). Children are an empty
898
+ // sentinel section since the bones own the layout during load.
899
+ return (_jsx(Skeleton, { name: "booking-widget", loading: true, children: _jsx("section", { className: "bw" }) }));
778
900
  }
779
901
  if (!setup || setup.services.length === 0) {
780
- return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
902
+ return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: t('errorNoServices') }) }));
781
903
  }
782
904
  if (forcedState === 'cancelled') {
783
- 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: "Booking cancelled" }), _jsx("p", { className: "bw-done-text", children: "Your appointment has been cancelled. We've let your provider know. You can book a new visit any time." }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
905
+ 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: () => {
784
906
  /* dev preview only — host wires the real handler */
785
- }, children: "Book a new visit" })] }) }));
907
+ }, children: t('cancelledCta') })] }) }));
786
908
  }
787
909
  if (success || forcedState === 'success') {
788
- 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 ? 'All set.' : "You're All Set!" }), _jsx("p", { className: "bw-done-text", children: isReschedule
789
- ? "Your appointment has been rescheduled. We've sent a confirmation to the email on file and notified your provider."
790
- : "Your booking request has been submitted. We'll follow up using the email you provided if anything needs confirmation." }), isReschedule ? null : (_jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
910
+ 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: () => {
791
911
  window.location.assign(successRedirectHref);
792
- }, children: "Back to home" }))] }) }));
912
+ }, children: t('successCtaBackHome') }))] }) }));
793
913
  }
794
- return (_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 ||
795
- (isReschedule
914
+ 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 ||
915
+ (isReschedule
916
+ ? rescheduleContext?.customerName
917
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
918
+ : t('rescheduleTitle')
919
+ : t('pageTitle')) }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
796
920
  ? rescheduleContext?.customerName
797
- ? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
798
- : 'Reschedule appointment'
799
- : 'Schedule your visit') }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
800
- ? rescheduleContext?.customerName
801
- ? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
802
- : 'Reschedule appointment'
803
- : 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] }), _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: "Selected service" }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: "Change" })] }), _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 ?? 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: selectedService.name }), selectedService.description ? (_jsx("span", { className: "bw-svc-desc", children: 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: "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: 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) => {
804
- const isActive = selectedServiceId === service._id;
805
- return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
806
- void prefetchAvailability({
807
- client,
808
- siteSlug,
809
- selectedServiceId: service._id,
810
- selectedStaffId,
811
- calendarMonth,
812
- cacheRef: availabilityCacheRef,
813
- });
814
- }, onFocus: () => {
815
- void prefetchAvailability({
816
- client,
817
- siteSlug,
818
- selectedServiceId: service._id,
819
- selectedStaffId,
820
- calendarMonth,
821
- cacheRef: availabilityCacheRef,
822
- });
823
- }, onClick: () => {
824
- handleServiceSelect(service);
825
- }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? 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: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: 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) })] })] })] }, service._id));
826
- })] }, group.key))) }) }) })] })) }), selectedService && selectedServiceRequiresSlot && !isEditingService ? (_jsxs("div", { className: "bw-provider-section", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: "Select a Provider" }), _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?.image?.url ? (_jsx("img", { src: selectedStaff.image.url, alt: selectedStaff.image.alt ?? selectedStaff.name, className: "bw-staff-avatar-img" })) : 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 provider' }), selectedStaff ? null : (_jsx("span", { className: "bw-staff-trigger-desc", children: "First available" }))] })] }), _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 provider menu", onClick: () => setStaffOpen(false) }), _jsxs("div", { className: "bw-staff-list", children: [_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => {
827
- setSelectedStaffId(null);
828
- setStaffOpen(false);
829
- }, 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" })] })] }), availableStaff.map((staffMember) => (_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === staffMember._id ? ' is-active' : ''}`, onClick: () => {
830
- setSelectedStaffId(staffMember._id);
831
- setStaffOpen(false);
832
- }, children: [_jsx("span", { className: "bw-staff-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-option-info", children: _jsx("span", { className: "bw-staff-option-name", children: staffMember.name }) })] }, staffMember._id)))] })] })) : null] })] })) : null] }), _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" }), 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) => {
833
- const isActive = selectedServiceId === service._id;
834
- return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
835
- void prefetchAvailability({
836
- client,
837
- siteSlug,
838
- selectedServiceId: service._id,
839
- selectedStaffId,
840
- calendarMonth,
841
- cacheRef: availabilityCacheRef,
842
- });
843
- }, onFocus: () => {
844
- void prefetchAvailability({
845
- client,
846
- siteSlug,
847
- selectedServiceId: service._id,
848
- selectedStaffId,
849
- calendarMonth,
850
- cacheRef: availabilityCacheRef,
851
- });
852
- }, onClick: () => {
853
- handleServiceSelect(service);
854
- }, 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 ?? 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: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: 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) })] })] })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
855
- })] }, group.key)))] })] }), _jsx("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) }), _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: () => {
856
- setCalendarMonth(option.date);
857
- setMonthOpen(false);
858
- }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
859
- if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
860
- void prefetchAvailability({
861
- client,
862
- siteSlug,
863
- selectedServiceId,
864
- selectedStaffId,
865
- calendarMonth: addMonths(calendarMonth, -1),
866
- cacheRef: availabilityCacheRef,
867
- });
868
- }
869
- }, 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: () => {
870
- if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
871
- void prefetchAvailability({
872
- client,
873
- siteSlug,
874
- selectedServiceId,
875
- selectedStaffId,
876
- calendarMonth: addMonths(calendarMonth, 1),
877
- cacheRef: availabilityCacheRef,
878
- });
879
- }
880
- }, 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: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
881
- const dateKey = formatDateKey(day);
882
- const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
883
- const isPast = dateKey < formatDateKey(startOfDay(new Date()));
884
- const slots = availabilityByDate.get(dateKey) ?? [];
885
- const isAvailable = slots.length > 0;
886
- const isSelected = selectedDate === dateKey;
887
- const className = [
888
- 'bw-cal-day',
889
- !isCurrentMonth ? 'is-outside' : '',
890
- isSelected ? 'is-selected' : '',
891
- isAvailable && !isPast ? 'is-available' : '',
892
- isPast ? 'is-disabled' : '',
893
- !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
894
- dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
895
- ]
896
- .filter(Boolean)
897
- .join(' ');
898
- return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
899
- setSelectedDate(dateKey);
900
- setSelectedSlot(null);
901
- setMobileStep((current) => (current < 3 ? 3 : current));
902
- }, children: day.getDate() }, dateKey));
903
- }) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: "Please select a service to see availability." })) : null, _jsxs("div", { className: "bw-slots-mobile", children: [selectedService && selectedDate && selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-divider" })) : null, slotsArea] }), _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.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-right-stage", children: [_jsx("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: _jsxs("div", { className: "bw-slots-desktop", children: [_jsxs("div", { className: "bw-slots-heading", children: [_jsx("span", { children: "Available times" }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), 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: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', 'Additional notes', 'Any preferences or special requests...'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? 'Reschedule Summary' : '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, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
904
- ...summaryValStyle,
905
- textDecoration: 'line-through',
906
- opacity: 0.55,
907
- }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
908
- ...summaryValStyle,
909
- textDecoration: 'line-through',
910
- opacity: 0.55,
911
- }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
912
- ...summaryValStyle,
913
- textDecoration: 'line-through',
914
- opacity: 0.55,
915
- }, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, 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", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _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 ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
916
- setSuccess('Payment received. Your booking is being confirmed.');
917
- setPayment(null);
918
- }, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
919
- ? isReschedule
920
- ? 'Rescheduling...'
921
- : 'Booking...'
922
- : isReschedule
923
- ? 'Confirm Reschedule'
924
- : selectedServiceRequiresPayment
925
- ? 'Continue to payment'
926
- : 'Confirm Booking' }), !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: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsx("h3", { className: "bw-details-service", children: selectedService.name }), selectedService.description ? (_jsx("p", { className: "bw-details-desc", children: 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: "Former time" }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })] })) : 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 ? 'New time' : 'When' }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate), _jsx("br", {}), formatTimeLabel(selectedSlot.time), " \u2013 ", formatTimeLabel(selectedSlot.endTime)] })] })] })) : 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 ?? 'Any Available' })] }), _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 ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, quote) })] })] }), 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: "Reservation hold" }), _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 ? 'Reason for reschedule' : 'Additional notes', isReschedule
927
- ? 'Let your provider know why...'
928
- : 'Any preferences or special requests...'), 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: () => {
929
- setSuccess('Payment received. Your booking is being confirmed.');
930
- setPayment(null);
931
- }, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
932
- ? isReschedule
933
- ? 'Rescheduling...'
934
- : 'Booking...'
935
- : forcedState === 'payment-full'
936
- ? `Pay ${formatPrice(selectedService)} & Confirm`
937
- : forcedState === 'payment-deposit'
938
- ? `Pay deposit & Confirm`
939
- : isReschedule
940
- ? 'Confirm Reschedule'
941
- : selectedServiceRequiresPayment
942
- ? 'Continue to payment'
943
- : '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: isReschedule ? 'Reschedule Summary' : '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, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
944
- ...summaryValStyle,
945
- textDecoration: 'line-through',
946
- opacity: 0.55,
947
- }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
948
- ...summaryValStyle,
949
- textDecoration: 'line-through',
950
- opacity: 0.55,
951
- }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
952
- ...summaryValStyle,
953
- textDecoration: 'line-through',
954
- opacity: 0.55,
955
- }, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, 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", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _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 ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => {
956
- if (!selectedServiceRequiresSlot && mobileStep === 4) {
957
- handleBackToServicePicker();
958
- }
959
- else {
960
- setMobileStep((step) => Math.max(1, step - 1));
961
- }
921
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
922
+ : t('rescheduleTitle')
923
+ : mobileStepTitle(mobileStep, t) }), mobileStep > 1 ? (_jsx("div", { className: "bw-progress", "aria-hidden": "true", children: (selectedServiceRequiresSlot
924
+ ? MOBILE_PROGRESS_STEPS_SCHEDULED
925
+ : 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, undefined, intlLocale) })] })] })] })] })) : (_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) => {
926
+ const isActive = selectedServiceId === service._id;
927
+ return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
928
+ void prefetchAvailability({
929
+ client,
930
+ siteSlug,
931
+ selectedServiceId: service._id,
932
+ calendarMonth,
933
+ cacheRef: availabilityCacheRef,
934
+ });
935
+ }, onFocus: () => {
936
+ void prefetchAvailability({
937
+ client,
938
+ siteSlug,
939
+ selectedServiceId: service._id,
940
+ calendarMonth,
941
+ cacheRef: availabilityCacheRef,
942
+ });
943
+ }, onClick: () => {
944
+ handleServiceSelect(service);
945
+ }, 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));
946
+ })] }, 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) => {
947
+ const isActive = selectedServiceId === service._id;
948
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
949
+ void prefetchAvailability({
950
+ client,
951
+ siteSlug,
952
+ selectedServiceId: service._id,
953
+ calendarMonth,
954
+ cacheRef: availabilityCacheRef,
955
+ });
956
+ }, onFocus: () => {
957
+ void prefetchAvailability({
958
+ client,
959
+ siteSlug,
960
+ selectedServiceId: service._id,
961
+ calendarMonth,
962
+ cacheRef: availabilityCacheRef,
963
+ });
964
+ }, onClick: () => {
965
+ handleServiceSelect(service);
966
+ }, 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));
967
+ })] }, 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: () => {
968
+ setCalendarMonth(option.date);
969
+ setMonthOpen(false);
970
+ }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
971
+ if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
972
+ void prefetchAvailability({
973
+ client,
974
+ siteSlug,
975
+ selectedServiceId,
976
+ calendarMonth: addMonths(calendarMonth, -1),
977
+ cacheRef: availabilityCacheRef,
978
+ });
979
+ }
980
+ }, 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: () => {
981
+ if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
982
+ void prefetchAvailability({
983
+ client,
984
+ siteSlug,
985
+ selectedServiceId,
986
+ calendarMonth: addMonths(calendarMonth, 1),
987
+ cacheRef: availabilityCacheRef,
988
+ });
989
+ }
990
+ }, 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: [
991
+ t('weekdaySun'),
992
+ t('weekdayMon'),
993
+ t('weekdayTue'),
994
+ t('weekdayWed'),
995
+ t('weekdayThu'),
996
+ t('weekdayFri'),
997
+ t('weekdaySat'),
998
+ ].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
999
+ const dateKey = formatDateKey(day);
1000
+ const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
1001
+ const isPast = dateKey < formatDateKey(startOfDay(new Date()));
1002
+ const slots = filteredAvailabilityByDate.get(dateKey) ?? [];
1003
+ const isAvailable = slots.length > 0;
1004
+ const isSelected = selectedDate === dateKey;
1005
+ const className = [
1006
+ 'bw-cal-day',
1007
+ !isCurrentMonth ? 'is-outside' : '',
1008
+ isSelected ? 'is-selected' : '',
1009
+ isAvailable && !isPast ? 'is-available' : '',
1010
+ isPast ? 'is-disabled' : '',
1011
+ !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
1012
+ dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
1013
+ ]
1014
+ .filter(Boolean)
1015
+ .join(' ');
1016
+ return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
1017
+ setSelectedDate(dateKey);
1018
+ setSelectedSlot(null);
1019
+ setMobileStep((current) => (current < 2 ? 2 : current));
1020
+ }, children: day.getDate() }, dateKey));
1021
+ }) }), !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') })] })] })] }), (() => {
1022
+ const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
1023
+ if (intakeRows.length === 0)
1024
+ return null;
1025
+ 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))) })] }));
1026
+ })(), 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: {
1027
+ ...summaryValStyle,
1028
+ textDecoration: 'line-through',
1029
+ opacity: 0.55,
1030
+ }, 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: {
1031
+ ...summaryValStyle,
1032
+ textDecoration: 'line-through',
1033
+ opacity: 0.55,
1034
+ }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerStaff') }), _jsx("span", { className: "bw-summary-val", style: {
1035
+ ...summaryValStyle,
1036
+ textDecoration: 'line-through',
1037
+ opacity: 0.55,
1038
+ }, 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: () => {
1039
+ setSuccess(t('successPaymentReceived'));
1040
+ setPayment(null);
1041
+ }, 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
1042
+ ? isReschedule
1043
+ ? t('btnRescheduling')
1044
+ : t('btnBooking')
1045
+ : isReschedule
1046
+ ? t('btnConfirmReschedule')
1047
+ : selectedServiceRequiresPayment
1048
+ ? t('btnContinueToPayment')
1049
+ : 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: () => {
1050
+ setSuccess(t('successPaymentReceived'));
1051
+ setPayment(null);
1052
+ }, 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
1053
+ ? isReschedule
1054
+ ? t('btnRescheduling')
1055
+ : t('btnBooking')
1056
+ : forcedState === 'payment-full'
1057
+ ? t('btnPayAndConfirm', { price: formatPrice(selectedService, intlLocale) })
1058
+ : forcedState === 'payment-deposit'
1059
+ ? t('btnPayDepositAndConfirm')
1060
+ : isReschedule
1061
+ ? t('btnConfirmReschedule')
1062
+ : selectedServiceRequiresPayment
1063
+ ? t('btnContinueToPayment')
1064
+ : 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: () => {
1065
+ setMobileStep((step) => {
1066
+ // Async services skip step 2 going backward too.
1067
+ // Step 3 step 1 instead of step 3 step 2.
1068
+ if (step === 3 && !selectedServiceRequiresSlot)
1069
+ return 1;
1070
+ return Math.max(1, step - 1);
1071
+ });
962
1072
  }, children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
963
1073
  (mobileStep === 2 && !canAdvanceStep2) ||
964
- (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
1074
+ (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => {
1075
+ // Async services skip step 2 (no date/time picker).
1076
+ // Step 1 → step 3 → step 4.
1077
+ if (step === 1 && !selectedServiceRequiresSlot)
1078
+ return 3;
1079
+ return Math.min(4, step + 1);
1080
+ }), 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
965
1081
  ? isReschedule
966
- ? 'Rescheduling...'
967
- : 'Booking...'
1082
+ ? t('btnRescheduling')
1083
+ : t('btnBooking')
968
1084
  : isReschedule
969
- ? 'Confirm Reschedule'
1085
+ ? t('btnConfirmReschedule')
970
1086
  : selectedServiceRequiresPayment
971
- ? 'Continue to payment'
972
- : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
1087
+ ? t('btnContinueToPayment')
1088
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] }, bookingFlowKey) }));
973
1089
  }
974
1090
  /* Payment panel — design proposal for the pay-before-booking flow.
975
1091
  * Renders inside the desktop details form (and mobile step-4 form
@@ -1007,22 +1123,24 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1007
1123
  * .bw-pay-card-slot is what gets replaced by <PaymentElement />.
1008
1124
  */
1009
1125
  function PaymentPanel({ service, mode, }) {
1126
+ const { t, locale } = useTranslation();
1010
1127
  if (!service)
1011
1128
  return null;
1012
- const formatter = currencyFormatter(service.currency);
1129
+ const formatter = currencyFormatter(service.currency, localeToIntl(locale));
1013
1130
  const total = service.priceCents / 100;
1014
1131
  // Deposit defaults to 30% of total when no explicit depositCents
1015
1132
  // is set on the service. Real flow reads `service.depositCents`.
1016
1133
  const depositToday = mode === 'full' ? total : Math.round(total * 0.3 * 100) / 100;
1017
1134
  const dueAtVisit = mode === 'full' ? 0 : Math.round((total - depositToday) * 100) / 100;
1018
- return (_jsxs("div", { className: "bw-pay", children: [_jsxs("div", { className: "bw-pay-summary", children: [_jsx("div", { className: "bw-pay-summary-header", children: "Summary" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: 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: "Subtotal" }), _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: "Tax" }), _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: "Total" }), _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: "Charged today" }), _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: "Due at visit" }), _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: "Card details" }), _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: "Card number" }), _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: "MM / YY" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "CVC" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "ZIP" }) })] })] }) }), _jsxs("p", { className: "bw-pay-secure", children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDD12" }), " Payments are processed securely by Stripe."] })] })] }));
1135
+ 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')] })] })] }));
1019
1136
  }
1020
1137
  function IntakeFormFields({ form, responses, onChange, idPrefix, mode, }) {
1138
+ const { locale } = useTranslation();
1021
1139
  if (!form || form.sections.length === 0)
1022
1140
  return null;
1023
1141
  return (_jsx("div", { className: "bw-intake", children: form.sections
1024
1142
  .filter((section) => !section.showWhenPath || section.showWhenPath === mode)
1025
- .map((section) => (_jsxs("div", { className: "bw-intake-section", children: [_jsx("div", { className: "bw-intake-title", children: section.titleEs ?? section.title ?? section.titleEn ?? 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))) }));
1143
+ .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))) }));
1026
1144
  }
1027
1145
  function areRequiredIntakeFieldsComplete(form, responses, mode) {
1028
1146
  return form.sections
@@ -1034,57 +1152,57 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1034
1152
  return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1035
1153
  }));
1036
1154
  }
1037
- function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, }) {
1155
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
1038
1156
  const blockers = [];
1039
1157
  if (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId)) {
1040
- blockers.push('Date and time');
1158
+ blockers.push(t('blockerDateTime'));
1041
1159
  }
1042
1160
  if (!customerName.trim())
1043
- blockers.push('Contact: Full name');
1161
+ blockers.push(t('blockerContactName'));
1044
1162
  if (!customerEmail.trim()) {
1045
- blockers.push('Contact: Email');
1163
+ blockers.push(t('blockerContactEmail'));
1046
1164
  }
1047
1165
  else if (!isValidEmailAddress(customerEmail)) {
1048
- blockers.push('Contact: Enter a valid email address');
1166
+ blockers.push(t('blockerContactEmailInvalid'));
1049
1167
  }
1050
1168
  if (!isIntakeComplete && selectedService.intakeForm) {
1051
- blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath));
1169
+ blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1052
1170
  }
1053
1171
  if (selectedService.pricingMode === 'calculated' &&
1054
1172
  isIntakeComplete &&
1055
1173
  !quote) {
1056
- blockers.push('Calculated price');
1174
+ blockers.push(t('blockerCalculatedPrice'));
1057
1175
  }
1058
1176
  return Array.from(new Set(blockers));
1059
1177
  }
1060
1178
  function isValidEmailAddress(value) {
1061
1179
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
1062
1180
  }
1063
- function getMissingRequiredIntakeLabels(form, responses, mode) {
1181
+ function getMissingRequiredIntakeLabels(form, responses, mode, t, locale = DEFAULT_LOCALE) {
1064
1182
  const missing = [];
1065
1183
  for (const section of form.sections) {
1066
1184
  if (section.showWhenPath && section.showWhenPath !== mode)
1067
1185
  continue;
1068
- const sectionLabel = getIntakeSectionLabel(section);
1186
+ const sectionLabel = getIntakeSectionLabel(section, locale);
1069
1187
  for (const field of section.fields) {
1070
1188
  const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1071
1189
  ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1072
1190
  : undefined;
1073
- collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, minItems);
1191
+ collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, t, locale, minItems);
1074
1192
  }
1075
1193
  }
1076
1194
  return missing;
1077
1195
  }
1078
- function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, minItems) {
1196
+ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, t, locale, minItems) {
1079
1197
  if (!field.required)
1080
1198
  return;
1081
1199
  if (field.type !== 'repeatable-group') {
1082
1200
  if (!isRequiredIntakeFieldComplete(field, value)) {
1083
- missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field)}`);
1201
+ missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field, locale)}`);
1084
1202
  }
1085
1203
  return;
1086
1204
  }
1087
- const fieldLabel = getIntakeFieldLabel(field);
1205
+ const fieldLabel = getIntakeFieldLabel(field, locale);
1088
1206
  const items = Array.isArray(value) ? value : [];
1089
1207
  const requiredItemCount = Math.max(minItems ?? 1, items.length || 1);
1090
1208
  if (items.length < requiredItemCount) {
@@ -1099,16 +1217,16 @@ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, mi
1099
1217
  if (!child.required)
1100
1218
  continue;
1101
1219
  if (!isRequiredIntakeFieldComplete(child, record[child.id])) {
1102
- missing.push(`${sectionLabel}: Caja ${index + 1} ${getIntakeFieldLabel(child)}`);
1220
+ missing.push(`${sectionLabel}: ${t('repeatItemLabel', { index: index + 1 })} ${getIntakeFieldLabel(child, locale)}`);
1103
1221
  }
1104
1222
  }
1105
1223
  }
1106
1224
  }
1107
- function getIntakeSectionLabel(section) {
1108
- return section.titleEs ?? section.title ?? section.titleEn ?? section.id;
1225
+ function getIntakeSectionLabel(section, locale = DEFAULT_LOCALE) {
1226
+ return pickLocaleField(section, 'title', locale) ?? section.id;
1109
1227
  }
1110
- function getIntakeFieldLabel(field) {
1111
- return field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1228
+ function getIntakeFieldLabel(field, locale = DEFAULT_LOCALE) {
1229
+ return pickLocaleField(field, 'label', locale) ?? field.id;
1112
1230
  }
1113
1231
  function isRequiredIntakeFieldComplete(field, value, minItems) {
1114
1232
  if (!field.required)
@@ -1135,6 +1253,62 @@ function isRequiredIntakeFieldComplete(field, value, minItems) {
1135
1253
  }
1136
1254
  return typeof value === 'string' && value.trim().length > 0;
1137
1255
  }
1256
+ function formatIntakeValue(field, value, locale, t) {
1257
+ if (field.type === 'checkbox') {
1258
+ return value === true ? t('reviewYes') : t('reviewNo');
1259
+ }
1260
+ if (field.type === 'select') {
1261
+ const option = (field.options ?? []).find((opt) => opt.value === value);
1262
+ if (option) {
1263
+ return (pickLocaleField(option, 'label', locale) ??
1264
+ option.label ??
1265
+ String(value));
1266
+ }
1267
+ }
1268
+ if (typeof value === 'string') {
1269
+ const trimmed = value.trim();
1270
+ return trimmed.length > 0 ? trimmed : t('reviewNotProvided');
1271
+ }
1272
+ if (typeof value === 'number')
1273
+ return String(value);
1274
+ return t('reviewNotProvided');
1275
+ }
1276
+ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1277
+ if (!form)
1278
+ return [];
1279
+ const rows = [];
1280
+ for (const section of form.sections) {
1281
+ if (section.showWhenPath && section.showWhenPath !== servicePath)
1282
+ continue;
1283
+ for (const field of section.fields) {
1284
+ if (field.type === 'repeatable-group') {
1285
+ const items = Array.isArray(responses[field.id]) ? responses[field.id] : [];
1286
+ items.forEach((item, index) => {
1287
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1288
+ ? item
1289
+ : {};
1290
+ const itemLabel = t('repeatItemLabel', { index: index + 1 });
1291
+ (field.fields ?? []).forEach((child) => {
1292
+ rows.push({
1293
+ key: `${field.id}-${index}-${child.id}`,
1294
+ label: `${itemLabel} · ${getIntakeFieldLabel(child, locale)}`,
1295
+ value: formatIntakeValue(child, record[child.id], locale, t),
1296
+ });
1297
+ });
1298
+ });
1299
+ continue;
1300
+ }
1301
+ const isMultiline = field.type === 'textarea';
1302
+ rows.push({
1303
+ key: field.id,
1304
+ label: getIntakeFieldLabel(field, locale),
1305
+ value: formatIntakeValue(field, responses[field.id], locale, t),
1306
+ multiline: isMultiline,
1307
+ });
1308
+ }
1309
+ }
1310
+ return rows;
1311
+ }
1138
1312
  function readFiniteNumber(value, fallback) {
1139
1313
  if (typeof value === 'number' && Number.isFinite(value))
1140
1314
  return value;
@@ -1145,26 +1319,194 @@ function readFiniteNumber(value, fallback) {
1145
1319
  }
1146
1320
  return fallback;
1147
1321
  }
1322
+ /**
1323
+ * Custom select that replaces the native <select>. The native control
1324
+ * renders differently on every OS/browser (macOS dark-mode picker,
1325
+ * Windows Chrome OS picker, etc.) and breaks the widget's visual
1326
+ * language. This implementation matches the .bw-field input chrome,
1327
+ * is fully keyboard-navigable (arrows, Home/End, Enter, Space, Escape,
1328
+ * Tab), respects prefers-reduced-motion, and uses the WAI-ARIA
1329
+ * combobox + listbox pattern for screen reader support.
1330
+ */
1331
+ function IntakeSelect({ id, value, onChange, options, placeholder, required, }) {
1332
+ const [isOpen, setIsOpen] = useState(false);
1333
+ const [focusedIndex, setFocusedIndex] = useState(-1);
1334
+ const [placement, setPlacement] = useState('below');
1335
+ const rootRef = useRef(null);
1336
+ const triggerRef = useRef(null);
1337
+ const listRef = useRef(null);
1338
+ const listboxId = `${id}-listbox`;
1339
+ /**
1340
+ * Decide which side to open the menu on. If the space below the
1341
+ * trigger isn't enough to fit the menu, and there's more space
1342
+ * above, flip the menu above the trigger. Runs at every open
1343
+ * (desktop AND mobile) — handles both the desktop near-footer
1344
+ * case and the mobile near-bottom-of-widget case.
1345
+ *
1346
+ * The menu's CSS max-height is `min(280px, 60svh)`. We compute
1347
+ * the same effective max here so the flip decision tracks the
1348
+ * real available space — important on short phones where 60svh
1349
+ * is much smaller than 280px.
1350
+ */
1351
+ function computePlacement() {
1352
+ if (typeof window === 'undefined')
1353
+ return 'below';
1354
+ const triggerEl = triggerRef.current;
1355
+ if (!triggerEl)
1356
+ return 'below';
1357
+ const rect = triggerEl.getBoundingClientRect();
1358
+ const effectiveMenuHeight = Math.min(280, window.innerHeight * 0.6);
1359
+ const padding = 16;
1360
+ const spaceBelow = window.innerHeight - rect.bottom - padding;
1361
+ const spaceAbove = rect.top - padding;
1362
+ if (spaceBelow < effectiveMenuHeight && spaceAbove > spaceBelow) {
1363
+ return 'above';
1364
+ }
1365
+ return 'below';
1366
+ }
1367
+ const currentIndex = options.findIndex((option) => option.value === value);
1368
+ const currentLabel = currentIndex >= 0 ? options[currentIndex].label : placeholder;
1369
+ function open(initialIndex) {
1370
+ if (isOpen)
1371
+ return;
1372
+ setPlacement(computePlacement());
1373
+ setIsOpen(true);
1374
+ const start = initialIndex !== undefined
1375
+ ? initialIndex
1376
+ : currentIndex >= 0
1377
+ ? currentIndex
1378
+ : 0;
1379
+ setFocusedIndex(Math.max(0, Math.min(options.length - 1, start)));
1380
+ }
1381
+ function close({ refocus = true } = {}) {
1382
+ setIsOpen(false);
1383
+ setFocusedIndex(-1);
1384
+ if (refocus && triggerRef.current)
1385
+ triggerRef.current.focus();
1386
+ }
1387
+ function selectAt(index) {
1388
+ const option = options[index];
1389
+ if (!option)
1390
+ return;
1391
+ onChange(option.value);
1392
+ close();
1393
+ }
1394
+ // Outside-click close. Listens on mousedown so the click on the
1395
+ // trigger itself doesn't immediately fire close + reopen.
1396
+ useEffect(() => {
1397
+ if (!isOpen)
1398
+ return;
1399
+ function handleDown(event) {
1400
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
1401
+ close({ refocus: false });
1402
+ }
1403
+ }
1404
+ document.addEventListener('mousedown', handleDown);
1405
+ return () => document.removeEventListener('mousedown', handleDown);
1406
+ }, [isOpen]);
1407
+ // Keep the keyboard-focused option in view as the user arrows
1408
+ // through. block: 'nearest' avoids unnecessary scroll jumps.
1409
+ useEffect(() => {
1410
+ if (!isOpen || focusedIndex < 0 || !listRef.current)
1411
+ return;
1412
+ const optionEl = listRef.current.querySelector(`[data-option-index="${focusedIndex}"]`);
1413
+ optionEl?.scrollIntoView({ block: 'nearest' });
1414
+ }, [focusedIndex, isOpen]);
1415
+ function handleTriggerKeyDown(event) {
1416
+ switch (event.key) {
1417
+ case 'ArrowDown':
1418
+ case 'ArrowUp':
1419
+ case 'Enter':
1420
+ case ' ':
1421
+ event.preventDefault();
1422
+ open(event.key === 'ArrowUp' ? options.length - 1 : undefined);
1423
+ break;
1424
+ case 'Home':
1425
+ event.preventDefault();
1426
+ open(0);
1427
+ break;
1428
+ case 'End':
1429
+ event.preventDefault();
1430
+ open(options.length - 1);
1431
+ break;
1432
+ }
1433
+ }
1434
+ function handleListKeyDown(event) {
1435
+ switch (event.key) {
1436
+ case 'ArrowDown':
1437
+ event.preventDefault();
1438
+ setFocusedIndex((current) => current >= options.length - 1 ? 0 : current + 1);
1439
+ break;
1440
+ case 'ArrowUp':
1441
+ event.preventDefault();
1442
+ setFocusedIndex((current) => current <= 0 ? options.length - 1 : current - 1);
1443
+ break;
1444
+ case 'Home':
1445
+ event.preventDefault();
1446
+ setFocusedIndex(0);
1447
+ break;
1448
+ case 'End':
1449
+ event.preventDefault();
1450
+ setFocusedIndex(options.length - 1);
1451
+ break;
1452
+ case 'Enter':
1453
+ case ' ':
1454
+ event.preventDefault();
1455
+ if (focusedIndex >= 0)
1456
+ selectAt(focusedIndex);
1457
+ break;
1458
+ case 'Escape':
1459
+ event.preventDefault();
1460
+ close();
1461
+ break;
1462
+ case 'Tab':
1463
+ // Close + let the browser move focus naturally.
1464
+ close({ refocus: false });
1465
+ break;
1466
+ }
1467
+ }
1468
+ const hasValue = currentIndex >= 0;
1469
+ 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,
1470
+ // Prevent the trigger from losing focus on click; we
1471
+ // manage focus ourselves via close({ refocus: true }).
1472
+ onMouseDown: (event) => event.preventDefault(), ref: (node) => {
1473
+ listRef.current = node;
1474
+ if (node)
1475
+ node.focus();
1476
+ }, children: options.map((option, index) => {
1477
+ const isSelected = option.value === value;
1478
+ const isFocused = index === focusedIndex;
1479
+ 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));
1480
+ }) })) : null] }));
1481
+ }
1148
1482
  function IntakeField({ field, value, onChange, idPrefix, }) {
1483
+ const { t, locale } = useTranslation();
1149
1484
  const id = `${idPrefix}-${field.id}`;
1150
- const label = field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1485
+ const label = pickLocaleField(field, 'label', locale) ?? field.id;
1151
1486
  const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
1152
1487
  if (field.type === 'repeatable-group') {
1153
1488
  const items = Array.isArray(value) && value.length > 0 ? value : [{}];
1154
1489
  return (_jsxs("div", { className: "bw-field bw-field--wide bw-repeatable", children: [_jsx("label", { children: label }), items.map((item, index) => {
1155
1490
  const record = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
1156
- return (_jsxs("div", { className: "bw-repeatable-item", children: [_jsxs("div", { className: "bw-repeatable-head", children: [_jsxs("span", { children: ["Caja ", index + 1] }), items.length > 1 ? (_jsx("button", { type: "button", className: "bw-link-btn", onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)), children: "Quitar" })) : 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) => {
1491
+ 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) => {
1157
1492
  const nextItems = [...items];
1158
1493
  nextItems[index] = { ...record, [child.id]: nextValue };
1159
1494
  onChange(nextItems);
1160
1495
  } }, child.id))) })] }, index));
1161
- }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: "Agregar otra caja" })] }));
1496
+ }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: t('repeatAdd') })] }));
1162
1497
  }
1163
1498
  if (field.type === 'checkbox') {
1164
1499
  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] }));
1165
1500
  }
1166
1501
  if (field.type === 'select') {
1167
- return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsxs("select", { id: id, value: stringValue, required: field.required, onChange: (event) => onChange(event.target.value), children: [_jsx("option", { value: "", children: "Selecciona" }), (field.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.labelEs ?? option.labelEn ?? option.label }, option.value)))] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1502
+ const options = (field.options ?? []).map((option) => ({
1503
+ value: option.value,
1504
+ label: (pickLocaleField(option, 'label', locale) ??
1505
+ option.labelEn ??
1506
+ option.label) ||
1507
+ option.value,
1508
+ }));
1509
+ 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] }));
1168
1510
  }
1169
1511
  if (field.type === 'textarea') {
1170
1512
  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] }));
@@ -1184,6 +1526,7 @@ function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, a
1184
1526
  }, children: _jsx(StripePaymentForm, { amountCents: payment.amountCents, currency: payment.currency, appointmentId: appointmentId, showInlineButton: showInlineButton, actionTarget: actionTarget, onSuccess: onSuccess, onError: onError }) }));
1185
1527
  }
1186
1528
  function StripePaymentForm({ amountCents, currency, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1529
+ const { t, locale } = useTranslation();
1187
1530
  const stripe = useStripe();
1188
1531
  const elements = useElements();
1189
1532
  const [isPaying, setIsPaying] = useState(false);
@@ -1205,41 +1548,41 @@ function StripePaymentForm({ amountCents, currency, appointmentId, showInlineBut
1205
1548
  }
1206
1549
  onSuccess();
1207
1550
  }
1208
- const renderPayButton = () => (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !stripe || !elements || isPaying, onClick: () => void handlePay(), children: [_jsx("span", { children: isPaying ? 'Procesando...' : 'Pagar y confirmar' }), !isPaying ? _jsx(ArrowRightIcon, {}) : null] }));
1209
- 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: "Total due today" }), _jsx("span", { className: "bw-pay-summary-val", children: currencyFormatter(currency).format(amountCents / 100) })] }) }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: "Pago seguro" }), _jsx("div", { className: "bw-pay-card-slot", children: _jsx(PaymentElement, {}) })] }), showInlineButton ? renderPayButton() : null, actionTarget ? createPortal(renderPayButton(), actionTarget) : null] }));
1551
+ 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] }));
1552
+ 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] }));
1210
1553
  }
1211
1554
  function ServiceWarningCallout({ warning }) {
1212
1555
  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] })] }));
1213
1556
  }
1214
- function formatPrice(service) {
1557
+ function formatPrice(service, locale = 'en-US') {
1215
1558
  if (!service)
1216
1559
  return '';
1217
- return formatServicePrice(service);
1560
+ return formatServicePrice(service, undefined, locale);
1218
1561
  }
1219
- function formatServicePrice(service, quote) {
1562
+ function formatServicePrice(service, quote, locale = 'en-US') {
1220
1563
  if (service.pricingMode === 'calculated') {
1221
1564
  if (quote) {
1222
- return currencyFormatter(quote.currency).format(quote.totalCents / 100);
1565
+ return currencyFormatter(quote.currency, locale).format(quote.totalCents / 100);
1223
1566
  }
1224
1567
  return 'Price calculated after details';
1225
1568
  }
1226
- return currencyFormatter(service.currency).format(service.priceCents / 100);
1569
+ return currencyFormatter(service.currency, locale).format(service.priceCents / 100);
1227
1570
  }
1228
- function getServiceWarning(service, mode) {
1571
+ function getServiceWarning(service, mode, locale = DEFAULT_LOCALE) {
1229
1572
  if (!service || mode !== 'async')
1230
1573
  return null;
1231
1574
  const warning = readRecord(service.publicCopy?.remoteWarning);
1232
1575
  if (!warning)
1233
1576
  return null;
1234
- const title = readString(warning.titleEs) ?? readString(warning.title);
1235
- const body = readString(warning.bodyEs) ?? readString(warning.body);
1577
+ const title = pickLocaleField(warning, 'title', locale);
1578
+ const body = pickLocaleField(warning, 'body', locale);
1236
1579
  if (!title || !body)
1237
1580
  return null;
1238
1581
  return {
1239
1582
  title,
1240
1583
  body,
1241
1584
  linkHref: readString(warning.linkHref) ?? undefined,
1242
- linkLabel: readString(warning.linkLabelEs) ?? readString(warning.linkLabel) ?? undefined,
1585
+ linkLabel: pickLocaleField(warning, 'linkLabel', locale),
1243
1586
  };
1244
1587
  }
1245
1588
  function readRecord(value) {
@@ -1250,16 +1593,11 @@ function readRecord(value) {
1250
1593
  function readString(value) {
1251
1594
  return typeof value === 'string' && value.trim() ? value : null;
1252
1595
  }
1253
- function BookingWidgetSkeleton() {
1254
- // Mirrors the live widget's DOM at first load so the chrome
1255
- // doesn't reflow when real content arrives. Three-column body on
1256
- // desktop (services / calendar / slots), single-column on mobile.
1257
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "bw-header", children: _jsx("div", { className: "bw-skel bw-skel--title" }) }), _jsx("div", { className: "bw-content", children: _jsxs("div", { className: "bw-body bw-skel-body", children: [_jsx("div", { className: "bw-col bw-col--left", children: _jsx("div", { className: "bw-step-1", children: _jsxs("div", { className: "bw-service-picker", children: [_jsx("div", { className: "bw-skel bw-skel--label" }), [
1258
- { rows: 1 },
1259
- { rows: 2 },
1260
- { rows: 1 },
1261
- ].map((group, groupIndex) => (_jsxs("div", { className: "bw-skel-svc-group", children: [_jsx("div", { className: "bw-skel bw-skel--svc-category" }), Array.from({ length: group.rows }).map((_, rowIndex) => (_jsxs("div", { className: "bw-skel-svc-row", children: [_jsx("div", { className: "bw-skel bw-skel--svc-image" }), _jsxs("div", { className: "bw-skel-svc-info", children: [_jsx("div", { className: "bw-skel bw-skel--svc-name" }), _jsx("div", { className: "bw-skel bw-skel--svc-desc" }), _jsx("div", { className: "bw-skel bw-skel--svc-meta" })] })] }, rowIndex)))] }, groupIndex)))] }) }) }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card bw-skel-cal-card", children: [_jsxs("div", { className: "bw-skel-cal-header", children: [_jsx("div", { className: "bw-skel bw-skel--cal-month" }), _jsxs("div", { className: "bw-skel-cal-navs", children: [_jsx("div", { className: "bw-skel bw-skel--cal-nav" }), _jsx("div", { className: "bw-skel bw-skel--cal-nav" })] })] }), _jsx("div", { className: "bw-skel-cal-dows", children: Array.from({ length: 7 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-dow" }, index))) }), _jsx("div", { className: "bw-skel-cal-grid", children: Array.from({ length: 42 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-day" }, index))) }), _jsx("div", { className: "bw-skel bw-skel--cal-tz" })] }) }), _jsx("div", { className: "bw-col bw-col--right", children: _jsxs("div", { className: "bw-pane--slots", children: [_jsx("div", { className: "bw-skel bw-skel--slots-heading" }), _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))) })] }) })] }) })] }));
1262
- }
1596
+ // BookingWidgetSkeleton was a hand-built first-load ghost that
1597
+ // constantly drifted out of sync with the real layout. Replaced by
1598
+ // boneyard-js: BookingWidgetPanel wraps its main render in
1599
+ // <Skeleton name="booking-widget"> and `./bones/registry` ships
1600
+ // pre-captured bones JSON regenerated via `npx boneyard-js build`.
1263
1601
  function AvailabilitySkeleton() {
1264
1602
  // Reuses .bw-time-slots so the skeleton inherits the same layout
1265
1603
  // overrides as the real slots — vertical 1-col on desktop (via the
@@ -1273,10 +1611,10 @@ function AvailabilitySkeleton() {
1273
1611
  * the backend stores absolute ms timestamps so this renders correctly
1274
1612
  * for the host's calendar.
1275
1613
  */
1276
- function formatFormerSlot(startMs, endMs) {
1614
+ function formatFormerSlot(startMs, endMs, locale = 'en-US') {
1277
1615
  const start = new Date(startMs);
1278
1616
  const end = new Date(endMs);
1279
- const dateLabel = start.toLocaleDateString([], {
1617
+ const dateLabel = start.toLocaleDateString(locale, {
1280
1618
  weekday: 'short',
1281
1619
  month: 'short',
1282
1620
  day: 'numeric',
@@ -1285,7 +1623,7 @@ function formatFormerSlot(startMs, endMs) {
1285
1623
  hour: 'numeric',
1286
1624
  minute: '2-digit',
1287
1625
  };
1288
- return `${dateLabel} · ${start.toLocaleTimeString([], timeOpts)} – ${end.toLocaleTimeString([], timeOpts)}`;
1626
+ return `${dateLabel} · ${start.toLocaleTimeString(locale, timeOpts)} – ${end.toLocaleTimeString(locale, timeOpts)}`;
1289
1627
  }
1290
1628
  /**
1291
1629
  * Possessive form of a name. "Eric Lan" → "Eric Lan's", "James" →
@@ -1301,18 +1639,18 @@ function formatPossessive(name) {
1301
1639
  return `${trimmed}'`;
1302
1640
  return `${trimmed}'s`;
1303
1641
  }
1304
- function mobileStepTitle(step) {
1642
+ function mobileStepTitle(step, t) {
1305
1643
  switch (step) {
1306
1644
  case 1:
1307
- return 'Choose a service';
1645
+ return t('stepChooseService');
1308
1646
  case 2:
1309
- return 'Pick a provider';
1647
+ return t('stepPickDateTime');
1310
1648
  case 3:
1311
- return 'Pick a date & time';
1649
+ return t('stepYourDetails');
1312
1650
  case 4:
1313
- return 'Your details';
1651
+ return t('stepReviewConfirm');
1314
1652
  default:
1315
- return 'Booking';
1653
+ return t('stepBooking');
1316
1654
  }
1317
1655
  }
1318
1656
  function formatDuration(minutes) {
@@ -1322,8 +1660,8 @@ function formatDuration(minutes) {
1322
1660
  }
1323
1661
  return `${minutes} min`;
1324
1662
  }
1325
- function currencyFormatter(currency) {
1326
- return new Intl.NumberFormat('en-US', {
1663
+ function currencyFormatter(currency, locale = 'en-US') {
1664
+ return new Intl.NumberFormat(locale, {
1327
1665
  style: 'currency',
1328
1666
  currency: currency || 'USD',
1329
1667
  minimumFractionDigits: 0,
@@ -1356,26 +1694,28 @@ function formatDateKey(date) {
1356
1694
  const day = `${date.getDate()}`.padStart(2, '0');
1357
1695
  return `${year}-${month}-${day}`;
1358
1696
  }
1359
- function formatMonthLabel(date) {
1360
- return new Intl.DateTimeFormat('en-US', {
1361
- month: 'long',
1362
- year: 'numeric',
1363
- }).format(date);
1697
+ function formatMonthLabel(date, locale = 'en-US') {
1698
+ // Use a simple "Month YYYY" shape across locales. Default Spanish
1699
+ // formatting yields "mayo de 2026" (lowercased, with "de"), which
1700
+ // we don't want — strip both for a tighter calendar header.
1701
+ const month = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date);
1702
+ const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
1703
+ return `${capitalizedMonth} ${date.getFullYear()}`;
1364
1704
  }
1365
- function formatReadableDate(dateKey) {
1366
- return new Intl.DateTimeFormat('en-US', {
1705
+ function formatReadableDate(dateKey, locale = 'en-US') {
1706
+ return new Intl.DateTimeFormat(locale, {
1367
1707
  weekday: 'short',
1368
1708
  month: 'short',
1369
1709
  day: 'numeric',
1370
1710
  }).format(new Date(`${dateKey}T00:00:00`));
1371
1711
  }
1372
- function formatTimeLabel(time) {
1712
+ function formatTimeLabel(time, locale = 'en-US') {
1373
1713
  const [hourString, minuteString] = time.split(':');
1374
1714
  const hour = Number(hourString);
1375
1715
  const minute = Number(minuteString);
1376
1716
  const date = new Date();
1377
1717
  date.setHours(hour, minute, 0, 0);
1378
- return new Intl.DateTimeFormat('en-US', {
1718
+ return new Intl.DateTimeFormat(locale, {
1379
1719
  hour: 'numeric',
1380
1720
  minute: '2-digit',
1381
1721
  }).format(date);
@@ -1405,13 +1745,13 @@ function buildCalendarDays(month) {
1405
1745
  function sameMonth(left, right) {
1406
1746
  return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
1407
1747
  }
1408
- function getMonthOptions(currentMonth, total) {
1748
+ function getMonthOptions(currentMonth, total, locale = 'en-US') {
1409
1749
  const start = startOfMonth(new Date());
1410
1750
  return Array.from({ length: total }, (_, index) => {
1411
1751
  const date = addMonths(start, index);
1412
1752
  return {
1413
1753
  value: optionCurrentValue(date),
1414
- label: formatMonthLabel(date),
1754
+ label: formatMonthLabel(date, locale),
1415
1755
  date,
1416
1756
  isCurrent: sameMonth(date, currentMonth),
1417
1757
  };
@@ -1431,15 +1771,14 @@ function findFirstAvailableDate(availabilityByDate, month) {
1431
1771
  .sort();
1432
1772
  return dates[0] ?? null;
1433
1773
  }
1434
- async function prefetchAvailability({ client, siteSlug, selectedServiceId, selectedStaffId, calendarMonth, cacheRef, }) {
1435
- const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
1774
+ async function prefetchAvailability({ client, siteSlug, selectedServiceId, calendarMonth, cacheRef, }) {
1775
+ const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, calendarMonth);
1436
1776
  if (cacheRef.current.has(requestKey)) {
1437
1777
  return;
1438
1778
  }
1439
1779
  const result = await client.getPublicAvailableSlots({
1440
1780
  siteSlug,
1441
1781
  serviceId: selectedServiceId,
1442
- staffMemberId: selectedStaffId ?? undefined,
1443
1782
  startDate: formatDateKey(calendarMonth),
1444
1783
  endDate: formatDateKey(endOfMonth(calendarMonth)),
1445
1784
  });
@@ -1449,8 +1788,8 @@ async function prefetchAvailability({ client, siteSlug, selectedServiceId, selec
1449
1788
  }
1450
1789
  cacheRef.current.set(requestKey, nextMap);
1451
1790
  }
1452
- function getAvailabilityCacheKey(siteSlug, serviceId, staffId, month) {
1453
- return `${siteSlug}:${serviceId}:${staffId ?? 'any'}:${optionCurrentValue(month)}`;
1791
+ function getAvailabilityCacheKey(siteSlug, serviceId, month) {
1792
+ return `${siteSlug}:${serviceId}:${optionCurrentValue(month)}`;
1454
1793
  }
1455
1794
  function formatHoldCountdown(totalSeconds) {
1456
1795
  const minutes = Math.floor(totalSeconds / 60);