@asksable/site-connector 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,29 @@ 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
615
+ .filter((staffMember) => staffIdsAvailableOnSelectedDate === null ||
616
+ staffIdsAvailableOnSelectedDate.has(staffMember._id))
617
+ .map((staffMember) => {
618
+ const isActive = selectedStaffId === staffMember._id;
619
+ return (_jsxs("button", { type: "button", role: "option", "aria-selected": isActive, className: `bw-staff-card${isActive ? ' is-active' : ''}`, onClick: () => setSelectedStaffId(staffMember._id), children: [_jsx("span", { className: "bw-staff-card-avatar", children: staffMember.image?.url ? (_jsx("img", { src: staffMember.image.url, alt: staffMember.image.alt ?? staffMember.name, className: "bw-staff-avatar-img" })) : (_jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) })) }), _jsx("span", { className: "bw-staff-card-info", children: _jsx("span", { className: "bw-staff-card-name", children: staffMember.name }) })] }, staffMember._id));
620
+ })] })) : null;
525
621
  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) => {
622
+ const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
527
623
  const slotKey = `${slot.time}-${slot.endTime}`;
528
624
  const isPending = pendingSlotKey === slotKey;
529
625
  const isActive = isPending ||
530
626
  (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));
627
+ return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, style: { '--bw-slot-i': index }, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? t('slotSecuring') : formatTimeLabel(slot.time, intlLocale) }, slotKey));
628
+ }) })) : (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') }))) : null }, slotsAreaKey));
533
629
  const canSubmit = Boolean(selectedService &&
534
630
  (!selectedServiceRequiresSlot || (selectedDate && selectedSlot && holdId)) &&
535
631
  isIntakeComplete &&
@@ -549,6 +645,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
549
645
  quote,
550
646
  customerName,
551
647
  customerEmail,
648
+ t,
649
+ locale,
552
650
  })
553
651
  : [];
554
652
  function handleNotesInput(event) {
@@ -562,16 +660,22 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
562
660
  return null;
563
661
  if (submitBlockers.length === 0 && !isQuoteLoading)
564
662
  return null;
663
+ // Only surface the "complete required fields" callout AFTER the
664
+ // user has tried to confirm. The Confirm button stays visually
665
+ // disabled but still receives the click via handleConfirmAttempt
666
+ // so we can flip submitAttempted and reveal the blockers list.
667
+ if (!submitAttempted)
668
+ return null;
565
669
  const visibleBlockers = submitBlockers.slice(0, 5);
566
670
  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] }));
671
+ return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isQuoteLoading ? t('helpCalculating') : t('helpComplete') }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? _jsx("li", { children: t('helpMoreFields', { count: hiddenCount }) }) : null] })) : null] }));
568
672
  }
569
673
  function renderContactFields(idSuffix) {
570
674
  const nameId = `bw-name${idSuffix}`;
571
675
  const emailId = `bw-email${idSuffix}`;
572
676
  const phoneId = `bw-phone${idSuffix}`;
573
677
  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" })] })] }));
678
+ return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: [t('contactFullName'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: t('placeholderFullName') })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: [t('contactEmail'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: t('placeholderEmail') }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: t('contactEmailInvalid') })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: t('contactPhone') }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: t('placeholderPhone') })] })] }));
575
679
  }
576
680
  function renderIntakeFields(idPrefix) {
577
681
  return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
@@ -579,7 +683,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
579
683
  function renderIntakeAndContact(idPrefix, idSuffix) {
580
684
  const intakeFields = renderIntakeFields(idPrefix);
581
685
  const contactFields = renderContactFields(idSuffix);
582
- return selectedServiceHasIntakeForm ? (_jsxs(_Fragment, { children: [intakeFields, contactFields] })) : (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
686
+ // Contact first, intake after. Customers expect "tell us who you
687
+ // are" before "tell us about your shipment" — name and email are
688
+ // identity, intake fields are service-specific details.
689
+ return (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
583
690
  }
584
691
  function handleServiceSelect(service) {
585
692
  if (holdId) {
@@ -598,7 +705,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
598
705
  setHoldExpiresAt(null);
599
706
  setHeldStaffId(null);
600
707
  setViewState('details');
601
- setMobileStep(4);
708
+ // Async = no date/time step; jump straight to the details form
709
+ // (step 3). The user advances to step 4 (review) via the Next
710
+ // button once the form is valid.
711
+ setMobileStep(3);
602
712
  }
603
713
  }
604
714
  async function handleSlotSelect(slot) {
@@ -616,7 +726,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
616
726
  const reservationStaffId = selectedStaffId ?? slot.availableStaffIds[0] ?? null;
617
727
  if (!reservationStaffId) {
618
728
  setPendingSlotKey(null);
619
- setError('No staff is available for that time.');
729
+ setError(t('errorNoStaffForSlot'));
620
730
  return;
621
731
  }
622
732
  setSelectedSlot(slot);
@@ -657,7 +767,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
657
767
  setHoldExpiresAt(previousHoldExpiresAt);
658
768
  setHeldStaffId(previousHeldStaffId);
659
769
  setPendingSlotKey(null);
660
- setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
770
+ setError(nextError instanceof Error ? nextError.message : t('errorHoldFailed'));
661
771
  }
662
772
  }
663
773
  async function handleBackToSlots() {
@@ -687,6 +797,16 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
687
797
  setViewState('slots');
688
798
  setMobileStep(1);
689
799
  }
800
+ function handleConfirmAttempt() {
801
+ // The disabled state is purely visual (aria-disabled + class). Real
802
+ // submittability gates here so we can surface validation help only
803
+ // after the user actually tries to confirm — never on a fresh form.
804
+ if (!canSubmit || isQuoteLoading || isSubmitting) {
805
+ setSubmitAttempted(true);
806
+ return;
807
+ }
808
+ void handleConfirmBooking();
809
+ }
690
810
  async function handleConfirmBooking() {
691
811
  if (!selectedServiceId ||
692
812
  !selectedService ||
@@ -711,7 +831,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
711
831
  if (isReschedule && onRescheduleSubmit) {
712
832
  try {
713
833
  await onRescheduleSubmit({ newStartTime, newEndTime });
714
- setSuccess('Reschedule confirmed.');
834
+ setSuccess(t('successRescheduleConfirmed'));
715
835
  setMobileStep(4);
716
836
  }
717
837
  catch (nextError) {
@@ -750,7 +870,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
750
870
  setError(null);
751
871
  return;
752
872
  }
753
- setSuccess('Booking confirmed.');
873
+ setSuccess(t('successBookingConfirmed'));
754
874
  setSelectedSlot(null);
755
875
  setPendingSlotKey(null);
756
876
  setHoldId(null);
@@ -767,209 +887,206 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
767
887
  setMobileStep(4);
768
888
  }
769
889
  catch (nextError) {
770
- setError(nextError instanceof Error ? nextError.message : 'Unable to confirm booking.');
890
+ setError(nextError instanceof Error ? nextError.message : t('errorConfirmFailed'));
771
891
  }
772
892
  finally {
773
893
  setIsSubmitting(false);
774
894
  }
775
895
  }
776
896
  if (isSetupLoading) {
777
- return (_jsx("section", { className: "bw", children: _jsx(BookingWidgetSkeleton, {}) }));
897
+ // Boneyard renders the bones JSON captured for "booking-widget"
898
+ // (see ./bones/booking-widget.bones.json). Children are an empty
899
+ // sentinel section since the bones own the layout during load.
900
+ return (_jsx(Skeleton, { name: "booking-widget", loading: true, children: _jsx("section", { className: "bw" }) }));
778
901
  }
779
902
  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." }) }));
903
+ return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: t('errorNoServices') }) }));
781
904
  }
782
905
  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: () => {
906
+ return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon bw-done-icon--muted", children: _jsx(XIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: t('cancelledTitle') }), _jsx("p", { className: "bw-done-text", children: t('cancelledBody') }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
784
907
  /* dev preview only — host wires the real handler */
785
- }, children: "Book a new visit" })] }) }));
908
+ }, children: t('cancelledCta') })] }) }));
786
909
  }
787
910
  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: () => {
911
+ return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(CheckIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: isReschedule ? t('successTitleReschedule') : t('successTitleCreate') }), _jsx("p", { className: "bw-done-text", children: isReschedule ? t('successBodyReschedule') : t('successBodyCreate') }), isReschedule ? null : (_jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
791
912
  window.location.assign(successRedirectHref);
792
- }, children: "Back to home" }))] }) }));
913
+ }, children: t('successCtaBackHome') }))] }) }));
793
914
  }
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
915
+ return (_jsx(Skeleton, { name: "booking-widget", loading: false, children: _jsxs("section", { className: "bw", "data-view-state": viewState, ref: widgetRootRef, children: [mobileHeader ? _jsx("div", { className: "bw-mobile-header", children: mobileHeader }) : null, _jsxs("div", { className: "bw-header", children: [_jsxs("div", { className: "bw-header-row", children: [_jsx("h2", { className: "bw-title bw-title--desktop", children: title ||
916
+ (isReschedule
917
+ ? rescheduleContext?.customerName
918
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
919
+ : t('rescheduleTitle')
920
+ : t('pageTitle')) }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
796
921
  ? 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
- }
922
+ ? t('rescheduleTitleNamed', { name: rescheduleContext.customerName })
923
+ : t('rescheduleTitle')
924
+ : mobileStepTitle(mobileStep, t) }), mobileStep > 1 ? (_jsx("div", { className: "bw-progress", "aria-hidden": "true", children: (selectedServiceRequiresSlot
925
+ ? MOBILE_PROGRESS_STEPS_SCHEDULED
926
+ : MOBILE_PROGRESS_STEPS_ASYNC).map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })) : null] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsxs("div", { className: "bw-content", children: [_jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("div", { className: "bw-service-picker", children: selectedService && !isEditingService ? (_jsxs("div", { className: "bw-svc-selected", children: [_jsxs("div", { className: "bw-svc-selected-header", children: [_jsx("label", { className: "bw-label", children: t('selectedService') }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: t('btnChange') })] }), _jsxs("div", { className: "bw-svc-row bw-svc-row--readonly", children: [selectedService.image ? (_jsxs("span", { className: "bw-svc-image is-checked", children: [_jsx("img", { src: selectedService.image.url, alt: selectedService.image.alt ?? pickLocaleField(selectedService, 'name', locale) ?? selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), pickLocaleField(selectedService, 'description', locale) ?? selectedService.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService) })] })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { className: "bw-label", children: t('selectService') }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
927
+ const isActive = selectedServiceId === service._id;
928
+ return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
929
+ void prefetchAvailability({
930
+ client,
931
+ siteSlug,
932
+ selectedServiceId: service._id,
933
+ calendarMonth,
934
+ cacheRef: availabilityCacheRef,
935
+ });
936
+ }, onFocus: () => {
937
+ void prefetchAvailability({
938
+ client,
939
+ siteSlug,
940
+ selectedServiceId: service._id,
941
+ calendarMonth,
942
+ cacheRef: availabilityCacheRef,
943
+ });
944
+ }, onClick: () => {
945
+ handleServiceSelect(service);
946
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? pickLocaleField(service, 'name', locale) ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(service, 'name', locale) ?? service.name }), pickLocaleField(service, 'description', locale) ?? service.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(service, 'description', locale) ?? service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service, undefined, intlLocale) })] })] })] }, service._id));
947
+ })] }, group.key))) }) }) })] })) }), selectedService && selectedServiceRequiresSlot && !isEditingService ? (_jsxs("div", { className: "bw-provider-section", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: t('chooseYourService') }), _jsx("div", { className: "bw-step-2-divider" }), serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
948
+ const isActive = selectedServiceId === service._id;
949
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
950
+ void prefetchAvailability({
951
+ client,
952
+ siteSlug,
953
+ selectedServiceId: service._id,
954
+ calendarMonth,
955
+ cacheRef: availabilityCacheRef,
956
+ });
957
+ }, onFocus: () => {
958
+ void prefetchAvailability({
959
+ client,
960
+ siteSlug,
961
+ selectedServiceId: service._id,
962
+ calendarMonth,
963
+ cacheRef: availabilityCacheRef,
964
+ });
965
+ }, onClick: () => {
966
+ handleServiceSelect(service);
967
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image bw-svc-image--lg${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? pickLocaleField(service, 'name', locale) ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check bw-check--lg${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(service, 'name', locale) ?? service.name }), pickLocaleField(service, 'description', locale) ?? service.description ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(service, 'description', locale) ?? service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service, undefined, intlLocale) })] })] })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
968
+ })] }, group.key)))] })] }), _jsxs("div", { className: "bw-col bw-col--center", children: [_jsxs("div", { className: "bw-cal-card", ref: calCardRef, children: [_jsxs("div", { className: "bw-cal-header", children: [_jsxs("div", { className: "bw-month-dropdown", children: [_jsxs("button", { type: "button", className: "bw-month-btn", onClick: () => setMonthOpen((current) => !current), children: [_jsx("span", { children: formatMonthLabel(calendarMonth, intlLocale) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": t('closeMonthPicker'), onClick: () => setMonthOpen(false) }), _jsx("div", { className: "bw-month-list", children: monthOptions.map((option) => (_jsx("button", { type: "button", className: `bw-month-option${option.value === optionCurrentValue(calendarMonth) ? ' is-active' : ''}`, onClick: () => {
969
+ setCalendarMonth(option.date);
970
+ setMonthOpen(false);
971
+ }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
972
+ if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
973
+ void prefetchAvailability({
974
+ client,
975
+ siteSlug,
976
+ selectedServiceId,
977
+ calendarMonth: addMonths(calendarMonth, -1),
978
+ cacheRef: availabilityCacheRef,
979
+ });
980
+ }
981
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": t('prevMonth'), children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
982
+ if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
983
+ void prefetchAvailability({
984
+ client,
985
+ siteSlug,
986
+ selectedServiceId,
987
+ calendarMonth: addMonths(calendarMonth, 1),
988
+ cacheRef: availabilityCacheRef,
989
+ });
990
+ }
991
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": t('nextMonth'), children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: [
992
+ t('weekdaySun'),
993
+ t('weekdayMon'),
994
+ t('weekdayTue'),
995
+ t('weekdayWed'),
996
+ t('weekdayThu'),
997
+ t('weekdayFri'),
998
+ t('weekdaySat'),
999
+ ].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
1000
+ const dateKey = formatDateKey(day);
1001
+ const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
1002
+ const isPast = dateKey < formatDateKey(startOfDay(new Date()));
1003
+ const slots = filteredAvailabilityByDate.get(dateKey) ?? [];
1004
+ const isAvailable = slots.length > 0;
1005
+ const isSelected = selectedDate === dateKey;
1006
+ const className = [
1007
+ 'bw-cal-day',
1008
+ !isCurrentMonth ? 'is-outside' : '',
1009
+ isSelected ? 'is-selected' : '',
1010
+ isAvailable && !isPast ? 'is-available' : '',
1011
+ isPast ? 'is-disabled' : '',
1012
+ !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
1013
+ dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
1014
+ ]
1015
+ .filter(Boolean)
1016
+ .join(' ');
1017
+ return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
1018
+ setSelectedDate(dateKey);
1019
+ setSelectedSlot(null);
1020
+ setMobileStep((current) => (current < 2 ? 2 : current));
1021
+ }, children: day.getDate() }, dateKey));
1022
+ }) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: t('calendarSelectServicePrompt') })) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] })] }), selectedService && !isEditingService ? (_jsxs("div", { className: "bw-mobile-card bw-provider-section--mobile", children: [_jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null, selectedService && selectedDate ? (_jsxs("div", { className: "bw-mobile-card bw-slots-mobile", children: [_jsx("label", { className: "bw-label", children: t('selectTimePrompt') }), slotsArea] })) : null] }), _jsx("div", { className: "bw-col bw-col--review", "aria-hidden": mobileStep !== 4, children: selectedService ? (_jsxs("div", { className: "bw-summary bw-summary--review", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? t('summaryTitleReschedule') : t('summaryTitleCreate') }), holdSecondsRemaining !== null ? (_jsx("div", { className: "bw-summary-rows", children: _jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] }) })) : null, _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewBookingDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerService') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerStaff') }), _jsx("span", { className: "bw-summary-val", style: { ...summaryValStyle, textDecoration: 'line-through', opacity: 0.55 }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] })] })] }), (() => {
1023
+ const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
1024
+ if (intakeRows.length === 0)
1025
+ return null;
1026
+ return (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewAdditionalInfo') }), _jsx("div", { className: "bw-summary-rows", children: intakeRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.multiline ? ' bw-summary-row--stack' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] }));
1027
+ })(), customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule ? t('notesLabelReschedule') : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote, intlLocale) })] }) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: t('slotsHeading') }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? t('summaryTitleReschedule') : t('summaryTitleCreate') }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: {
1028
+ ...summaryValStyle,
1029
+ textDecoration: 'line-through',
1030
+ opacity: 0.55,
1031
+ }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerService') }), _jsx("span", { className: "bw-summary-val", style: {
1032
+ ...summaryValStyle,
1033
+ textDecoration: 'line-through',
1034
+ opacity: 0.55,
1035
+ }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerStaff') }), _jsx("span", { className: "bw-summary-val", style: {
1036
+ ...summaryValStyle,
1037
+ textDecoration: 'line-through',
1038
+ opacity: 0.55,
1039
+ }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote, intlLocale) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
1040
+ setSuccess(t('successPaymentReceived'));
1041
+ setPayment(null);
1042
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
1043
+ ? isReschedule
1044
+ ? t('btnRescheduling')
1045
+ : t('btnBooking')
1046
+ : isReschedule
1047
+ ? t('btnConfirmReschedule')
1048
+ : selectedServiceRequiresPayment
1049
+ ? t('btnContinueToPayment')
1050
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-details-view", children: [_jsx("div", { className: "bw-details-summary", children: selectedService ? (_jsxs(_Fragment, { children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsx("h3", { className: "bw-details-service", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), pickLocaleField(selectedService, 'description', locale) ?? selectedService.description ? (_jsx("p", { className: "bw-details-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryFormerTime') }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })] })) : null, selectedDate && selectedSlot ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isReschedule ? t('summaryNewTime') : t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}), formatTimeLabel(selectedSlot.time, intlLocale), " \u2013 ", formatTimeLabel(selectedSlot.endTime, intlLocale)] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? t('providerAny') })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isQuoteLoading ? t('summaryCalculating') : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, quote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule ? t('notesPlaceholderReschedule') : t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment && (forcedState === 'payment-full' || forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
1051
+ setSuccess(t('successPaymentReceived'));
1052
+ setPayment(null);
1053
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
1054
+ ? isReschedule
1055
+ ? t('btnRescheduling')
1056
+ : t('btnBooking')
1057
+ : forcedState === 'payment-full'
1058
+ ? t('btnPayAndConfirm', { price: formatPrice(selectedService, intlLocale) })
1059
+ : forcedState === 'payment-deposit'
1060
+ ? t('btnPayDepositAndConfirm')
1061
+ : isReschedule
1062
+ ? t('btnConfirmReschedule')
1063
+ : selectedServiceRequiresPayment
1064
+ ? t('btnContinueToPayment')
1065
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] })] }), _jsx("div", { className: "bw-footer", children: _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => {
1066
+ setMobileStep((step) => {
1067
+ // Async services skip step 2 going backward too.
1068
+ // Step 3 step 1 instead of step 3 step 2.
1069
+ if (step === 3 && !selectedServiceRequiresSlot)
1070
+ return 1;
1071
+ return Math.max(1, step - 1);
1072
+ });
962
1073
  }, children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
963
1074
  (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
1075
+ (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => {
1076
+ // Async services skip step 2 (no date/time picker).
1077
+ // Step 1 → step 3 → step 4.
1078
+ if (step === 1 && !selectedServiceRequiresSlot)
1079
+ return 3;
1080
+ return Math.min(4, step + 1);
1081
+ }), children: [_jsx("span", { children: t('btnNext') }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: `bw-footer-next${!canSubmit || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
965
1082
  ? isReschedule
966
- ? 'Rescheduling...'
967
- : 'Booking...'
1083
+ ? t('btnRescheduling')
1084
+ : t('btnBooking')
968
1085
  : isReschedule
969
- ? 'Confirm Reschedule'
1086
+ ? t('btnConfirmReschedule')
970
1087
  : selectedServiceRequiresPayment
971
- ? 'Continue to payment'
972
- : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
1088
+ ? t('btnContinueToPayment')
1089
+ : t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] }, bookingFlowKey) }));
973
1090
  }
974
1091
  /* Payment panel — design proposal for the pay-before-booking flow.
975
1092
  * Renders inside the desktop details form (and mobile step-4 form
@@ -1007,22 +1124,24 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1007
1124
  * .bw-pay-card-slot is what gets replaced by <PaymentElement />.
1008
1125
  */
1009
1126
  function PaymentPanel({ service, mode, }) {
1127
+ const { t, locale } = useTranslation();
1010
1128
  if (!service)
1011
1129
  return null;
1012
- const formatter = currencyFormatter(service.currency);
1130
+ const formatter = currencyFormatter(service.currency, localeToIntl(locale));
1013
1131
  const total = service.priceCents / 100;
1014
1132
  // Deposit defaults to 30% of total when no explicit depositCents
1015
1133
  // is set on the service. Real flow reads `service.depositCents`.
1016
1134
  const depositToday = mode === 'full' ? total : Math.round(total * 0.3 * 100) / 100;
1017
1135
  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."] })] })] }));
1136
+ return (_jsxs("div", { className: "bw-pay", children: [_jsxs("div", { className: "bw-pay-summary", children: [_jsx("div", { className: "bw-pay-summary-header", children: t('paymentHeader') }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: pickLocaleField(service, 'name', locale) ?? service.name }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: t('paymentSubtotal') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: t('paymentTax') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(0) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--total", children: [_jsx("span", { children: t('paymentTotal') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: t('paymentChargedToday') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(depositToday) })] }), mode === 'deposit' ? (_jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: t('paymentDueAtVisit') }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(dueAtVisit) })] })) : null] }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: t('paymentCardDetails') }), _jsx("div", { className: "bw-pay-card-slot", children: _jsxs("div", { className: "bw-pay-card-stub", children: [_jsxs("div", { className: "bw-pay-card-stub-row", children: [_jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentCardNumber') }), _jsx("span", { className: "bw-pay-card-stub-value", children: "1234 1234 1234 1234" })] }), _jsxs("div", { className: "bw-pay-card-stub-grid", children: [_jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentExpiry') }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentCvc') }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: t('paymentZip') }) })] })] }) }), _jsxs("p", { className: "bw-pay-secure", children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDD12" }), " ", t('paymentSecureNote')] })] })] }));
1019
1137
  }
1020
1138
  function IntakeFormFields({ form, responses, onChange, idPrefix, mode, }) {
1139
+ const { locale } = useTranslation();
1021
1140
  if (!form || form.sections.length === 0)
1022
1141
  return null;
1023
1142
  return (_jsx("div", { className: "bw-intake", children: form.sections
1024
1143
  .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))) }));
1144
+ .map((section) => (_jsxs("div", { className: "bw-intake-section", children: [_jsx("div", { className: "bw-intake-title", children: pickLocaleField(section, 'title', locale) ?? section.id }), _jsx("div", { className: "bw-form-fields", children: section.fields.map((field) => (_jsx(IntakeField, { field: field, value: responses[field.id], onChange: (value) => onChange(field.id, value), idPrefix: `${idPrefix}-${section.id}` }, field.id))) })] }, section.id))) }));
1026
1145
  }
1027
1146
  function areRequiredIntakeFieldsComplete(form, responses, mode) {
1028
1147
  return form.sections
@@ -1034,57 +1153,57 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1034
1153
  return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1035
1154
  }));
1036
1155
  }
1037
- function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, }) {
1156
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
1038
1157
  const blockers = [];
1039
1158
  if (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId)) {
1040
- blockers.push('Date and time');
1159
+ blockers.push(t('blockerDateTime'));
1041
1160
  }
1042
1161
  if (!customerName.trim())
1043
- blockers.push('Contact: Full name');
1162
+ blockers.push(t('blockerContactName'));
1044
1163
  if (!customerEmail.trim()) {
1045
- blockers.push('Contact: Email');
1164
+ blockers.push(t('blockerContactEmail'));
1046
1165
  }
1047
1166
  else if (!isValidEmailAddress(customerEmail)) {
1048
- blockers.push('Contact: Enter a valid email address');
1167
+ blockers.push(t('blockerContactEmailInvalid'));
1049
1168
  }
1050
1169
  if (!isIntakeComplete && selectedService.intakeForm) {
1051
- blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath));
1170
+ blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1052
1171
  }
1053
1172
  if (selectedService.pricingMode === 'calculated' &&
1054
1173
  isIntakeComplete &&
1055
1174
  !quote) {
1056
- blockers.push('Calculated price');
1175
+ blockers.push(t('blockerCalculatedPrice'));
1057
1176
  }
1058
1177
  return Array.from(new Set(blockers));
1059
1178
  }
1060
1179
  function isValidEmailAddress(value) {
1061
1180
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
1062
1181
  }
1063
- function getMissingRequiredIntakeLabels(form, responses, mode) {
1182
+ function getMissingRequiredIntakeLabels(form, responses, mode, t, locale = DEFAULT_LOCALE) {
1064
1183
  const missing = [];
1065
1184
  for (const section of form.sections) {
1066
1185
  if (section.showWhenPath && section.showWhenPath !== mode)
1067
1186
  continue;
1068
- const sectionLabel = getIntakeSectionLabel(section);
1187
+ const sectionLabel = getIntakeSectionLabel(section, locale);
1069
1188
  for (const field of section.fields) {
1070
1189
  const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1071
1190
  ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1072
1191
  : undefined;
1073
- collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, minItems);
1192
+ collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, t, locale, minItems);
1074
1193
  }
1075
1194
  }
1076
1195
  return missing;
1077
1196
  }
1078
- function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, minItems) {
1197
+ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, t, locale, minItems) {
1079
1198
  if (!field.required)
1080
1199
  return;
1081
1200
  if (field.type !== 'repeatable-group') {
1082
1201
  if (!isRequiredIntakeFieldComplete(field, value)) {
1083
- missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field)}`);
1202
+ missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field, locale)}`);
1084
1203
  }
1085
1204
  return;
1086
1205
  }
1087
- const fieldLabel = getIntakeFieldLabel(field);
1206
+ const fieldLabel = getIntakeFieldLabel(field, locale);
1088
1207
  const items = Array.isArray(value) ? value : [];
1089
1208
  const requiredItemCount = Math.max(minItems ?? 1, items.length || 1);
1090
1209
  if (items.length < requiredItemCount) {
@@ -1099,16 +1218,16 @@ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, mi
1099
1218
  if (!child.required)
1100
1219
  continue;
1101
1220
  if (!isRequiredIntakeFieldComplete(child, record[child.id])) {
1102
- missing.push(`${sectionLabel}: Caja ${index + 1} ${getIntakeFieldLabel(child)}`);
1221
+ missing.push(`${sectionLabel}: ${t('repeatItemLabel', { index: index + 1 })} ${getIntakeFieldLabel(child, locale)}`);
1103
1222
  }
1104
1223
  }
1105
1224
  }
1106
1225
  }
1107
- function getIntakeSectionLabel(section) {
1108
- return section.titleEs ?? section.title ?? section.titleEn ?? section.id;
1226
+ function getIntakeSectionLabel(section, locale = DEFAULT_LOCALE) {
1227
+ return pickLocaleField(section, 'title', locale) ?? section.id;
1109
1228
  }
1110
- function getIntakeFieldLabel(field) {
1111
- return field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1229
+ function getIntakeFieldLabel(field, locale = DEFAULT_LOCALE) {
1230
+ return pickLocaleField(field, 'label', locale) ?? field.id;
1112
1231
  }
1113
1232
  function isRequiredIntakeFieldComplete(field, value, minItems) {
1114
1233
  if (!field.required)
@@ -1135,6 +1254,62 @@ function isRequiredIntakeFieldComplete(field, value, minItems) {
1135
1254
  }
1136
1255
  return typeof value === 'string' && value.trim().length > 0;
1137
1256
  }
1257
+ function formatIntakeValue(field, value, locale, t) {
1258
+ if (field.type === 'checkbox') {
1259
+ return value === true ? t('reviewYes') : t('reviewNo');
1260
+ }
1261
+ if (field.type === 'select') {
1262
+ const option = (field.options ?? []).find((opt) => opt.value === value);
1263
+ if (option) {
1264
+ return (pickLocaleField(option, 'label', locale) ??
1265
+ option.label ??
1266
+ String(value));
1267
+ }
1268
+ }
1269
+ if (typeof value === 'string') {
1270
+ const trimmed = value.trim();
1271
+ return trimmed.length > 0 ? trimmed : t('reviewNotProvided');
1272
+ }
1273
+ if (typeof value === 'number')
1274
+ return String(value);
1275
+ return t('reviewNotProvided');
1276
+ }
1277
+ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1278
+ if (!form)
1279
+ return [];
1280
+ const rows = [];
1281
+ for (const section of form.sections) {
1282
+ if (section.showWhenPath && section.showWhenPath !== servicePath)
1283
+ continue;
1284
+ for (const field of section.fields) {
1285
+ if (field.type === 'repeatable-group') {
1286
+ const items = Array.isArray(responses[field.id]) ? responses[field.id] : [];
1287
+ items.forEach((item, index) => {
1288
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1289
+ ? item
1290
+ : {};
1291
+ const itemLabel = t('repeatItemLabel', { index: index + 1 });
1292
+ (field.fields ?? []).forEach((child) => {
1293
+ rows.push({
1294
+ key: `${field.id}-${index}-${child.id}`,
1295
+ label: `${itemLabel} · ${getIntakeFieldLabel(child, locale)}`,
1296
+ value: formatIntakeValue(child, record[child.id], locale, t),
1297
+ });
1298
+ });
1299
+ });
1300
+ continue;
1301
+ }
1302
+ const isMultiline = field.type === 'textarea';
1303
+ rows.push({
1304
+ key: field.id,
1305
+ label: getIntakeFieldLabel(field, locale),
1306
+ value: formatIntakeValue(field, responses[field.id], locale, t),
1307
+ multiline: isMultiline,
1308
+ });
1309
+ }
1310
+ }
1311
+ return rows;
1312
+ }
1138
1313
  function readFiniteNumber(value, fallback) {
1139
1314
  if (typeof value === 'number' && Number.isFinite(value))
1140
1315
  return value;
@@ -1145,26 +1320,194 @@ function readFiniteNumber(value, fallback) {
1145
1320
  }
1146
1321
  return fallback;
1147
1322
  }
1323
+ /**
1324
+ * Custom select that replaces the native <select>. The native control
1325
+ * renders differently on every OS/browser (macOS dark-mode picker,
1326
+ * Windows Chrome OS picker, etc.) and breaks the widget's visual
1327
+ * language. This implementation matches the .bw-field input chrome,
1328
+ * is fully keyboard-navigable (arrows, Home/End, Enter, Space, Escape,
1329
+ * Tab), respects prefers-reduced-motion, and uses the WAI-ARIA
1330
+ * combobox + listbox pattern for screen reader support.
1331
+ */
1332
+ function IntakeSelect({ id, value, onChange, options, placeholder, required, }) {
1333
+ const [isOpen, setIsOpen] = useState(false);
1334
+ const [focusedIndex, setFocusedIndex] = useState(-1);
1335
+ const [placement, setPlacement] = useState('below');
1336
+ const rootRef = useRef(null);
1337
+ const triggerRef = useRef(null);
1338
+ const listRef = useRef(null);
1339
+ const listboxId = `${id}-listbox`;
1340
+ /**
1341
+ * Decide which side to open the menu on. If the space below the
1342
+ * trigger isn't enough to fit the menu, and there's more space
1343
+ * above, flip the menu above the trigger. Runs at every open
1344
+ * (desktop AND mobile) — handles both the desktop near-footer
1345
+ * case and the mobile near-bottom-of-widget case.
1346
+ *
1347
+ * The menu's CSS max-height is `min(280px, 60svh)`. We compute
1348
+ * the same effective max here so the flip decision tracks the
1349
+ * real available space — important on short phones where 60svh
1350
+ * is much smaller than 280px.
1351
+ */
1352
+ function computePlacement() {
1353
+ if (typeof window === 'undefined')
1354
+ return 'below';
1355
+ const triggerEl = triggerRef.current;
1356
+ if (!triggerEl)
1357
+ return 'below';
1358
+ const rect = triggerEl.getBoundingClientRect();
1359
+ const effectiveMenuHeight = Math.min(280, window.innerHeight * 0.6);
1360
+ const padding = 16;
1361
+ const spaceBelow = window.innerHeight - rect.bottom - padding;
1362
+ const spaceAbove = rect.top - padding;
1363
+ if (spaceBelow < effectiveMenuHeight && spaceAbove > spaceBelow) {
1364
+ return 'above';
1365
+ }
1366
+ return 'below';
1367
+ }
1368
+ const currentIndex = options.findIndex((option) => option.value === value);
1369
+ const currentLabel = currentIndex >= 0 ? options[currentIndex].label : placeholder;
1370
+ function open(initialIndex) {
1371
+ if (isOpen)
1372
+ return;
1373
+ setPlacement(computePlacement());
1374
+ setIsOpen(true);
1375
+ const start = initialIndex !== undefined
1376
+ ? initialIndex
1377
+ : currentIndex >= 0
1378
+ ? currentIndex
1379
+ : 0;
1380
+ setFocusedIndex(Math.max(0, Math.min(options.length - 1, start)));
1381
+ }
1382
+ function close({ refocus = true } = {}) {
1383
+ setIsOpen(false);
1384
+ setFocusedIndex(-1);
1385
+ if (refocus && triggerRef.current)
1386
+ triggerRef.current.focus();
1387
+ }
1388
+ function selectAt(index) {
1389
+ const option = options[index];
1390
+ if (!option)
1391
+ return;
1392
+ onChange(option.value);
1393
+ close();
1394
+ }
1395
+ // Outside-click close. Listens on mousedown so the click on the
1396
+ // trigger itself doesn't immediately fire close + reopen.
1397
+ useEffect(() => {
1398
+ if (!isOpen)
1399
+ return;
1400
+ function handleDown(event) {
1401
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
1402
+ close({ refocus: false });
1403
+ }
1404
+ }
1405
+ document.addEventListener('mousedown', handleDown);
1406
+ return () => document.removeEventListener('mousedown', handleDown);
1407
+ }, [isOpen]);
1408
+ // Keep the keyboard-focused option in view as the user arrows
1409
+ // through. block: 'nearest' avoids unnecessary scroll jumps.
1410
+ useEffect(() => {
1411
+ if (!isOpen || focusedIndex < 0 || !listRef.current)
1412
+ return;
1413
+ const optionEl = listRef.current.querySelector(`[data-option-index="${focusedIndex}"]`);
1414
+ optionEl?.scrollIntoView({ block: 'nearest' });
1415
+ }, [focusedIndex, isOpen]);
1416
+ function handleTriggerKeyDown(event) {
1417
+ switch (event.key) {
1418
+ case 'ArrowDown':
1419
+ case 'ArrowUp':
1420
+ case 'Enter':
1421
+ case ' ':
1422
+ event.preventDefault();
1423
+ open(event.key === 'ArrowUp' ? options.length - 1 : undefined);
1424
+ break;
1425
+ case 'Home':
1426
+ event.preventDefault();
1427
+ open(0);
1428
+ break;
1429
+ case 'End':
1430
+ event.preventDefault();
1431
+ open(options.length - 1);
1432
+ break;
1433
+ }
1434
+ }
1435
+ function handleListKeyDown(event) {
1436
+ switch (event.key) {
1437
+ case 'ArrowDown':
1438
+ event.preventDefault();
1439
+ setFocusedIndex((current) => current >= options.length - 1 ? 0 : current + 1);
1440
+ break;
1441
+ case 'ArrowUp':
1442
+ event.preventDefault();
1443
+ setFocusedIndex((current) => current <= 0 ? options.length - 1 : current - 1);
1444
+ break;
1445
+ case 'Home':
1446
+ event.preventDefault();
1447
+ setFocusedIndex(0);
1448
+ break;
1449
+ case 'End':
1450
+ event.preventDefault();
1451
+ setFocusedIndex(options.length - 1);
1452
+ break;
1453
+ case 'Enter':
1454
+ case ' ':
1455
+ event.preventDefault();
1456
+ if (focusedIndex >= 0)
1457
+ selectAt(focusedIndex);
1458
+ break;
1459
+ case 'Escape':
1460
+ event.preventDefault();
1461
+ close();
1462
+ break;
1463
+ case 'Tab':
1464
+ // Close + let the browser move focus naturally.
1465
+ close({ refocus: false });
1466
+ break;
1467
+ }
1468
+ }
1469
+ const hasValue = currentIndex >= 0;
1470
+ return (_jsxs("div", { className: "bw-select", ref: rootRef, children: [_jsxs("button", { ref: triggerRef, type: "button", id: id, className: `bw-select-trigger${isOpen ? ' is-open' : ''}${hasValue ? ' has-value' : ''}`, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-required": required ? true : undefined, onClick: () => (isOpen ? close({ refocus: false }) : open()), onKeyDown: handleTriggerKeyDown, children: [_jsx("span", { className: `bw-select-value${hasValue ? '' : ' is-placeholder'}`, children: currentLabel }), _jsx("span", { className: "bw-select-chevron", "aria-hidden": "true", children: _jsx(ChevronDownIcon, {}) })] }), isOpen ? (_jsx("ul", { id: listboxId, role: "listbox", tabIndex: -1, "aria-activedescendant": focusedIndex >= 0 ? `${id}-option-${focusedIndex}` : undefined, className: `bw-select-menu${placement === 'above' ? ' is-above' : ''}`, onKeyDown: handleListKeyDown,
1471
+ // Prevent the trigger from losing focus on click; we
1472
+ // manage focus ourselves via close({ refocus: true }).
1473
+ onMouseDown: (event) => event.preventDefault(), ref: (node) => {
1474
+ listRef.current = node;
1475
+ if (node)
1476
+ node.focus();
1477
+ }, children: options.map((option, index) => {
1478
+ const isSelected = option.value === value;
1479
+ const isFocused = index === focusedIndex;
1480
+ return (_jsxs("li", { id: `${id}-option-${index}`, "data-option-index": index, role: "option", "aria-selected": isSelected, className: `bw-select-option${isSelected ? ' is-active' : ''}${isFocused ? ' is-focused' : ''}`, onMouseEnter: () => setFocusedIndex(index), onClick: () => selectAt(index), children: [_jsx("span", { className: "bw-select-option-label", children: option.label }), isSelected ? (_jsx("span", { className: "bw-select-option-check", "aria-hidden": "true", children: _jsx(CheckIcon, {}) })) : null] }, option.value));
1481
+ }) })) : null] }));
1482
+ }
1148
1483
  function IntakeField({ field, value, onChange, idPrefix, }) {
1484
+ const { t, locale } = useTranslation();
1149
1485
  const id = `${idPrefix}-${field.id}`;
1150
- const label = field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1486
+ const label = pickLocaleField(field, 'label', locale) ?? field.id;
1151
1487
  const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
1152
1488
  if (field.type === 'repeatable-group') {
1153
1489
  const items = Array.isArray(value) && value.length > 0 ? value : [{}];
1154
1490
  return (_jsxs("div", { className: "bw-field bw-field--wide bw-repeatable", children: [_jsx("label", { children: label }), items.map((item, index) => {
1155
1491
  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) => {
1492
+ return (_jsxs("div", { className: "bw-repeatable-item", children: [_jsxs("div", { className: "bw-repeatable-head", children: [_jsx("span", { children: t('repeatItemLabel', { index: index + 1 }) }), items.length > 1 ? (_jsx("button", { type: "button", className: "bw-link-btn", onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)), children: t('repeatRemove') })) : null] }), _jsx("div", { className: "bw-form-fields", children: (field.fields ?? []).map((child) => (_jsx(IntakeField, { field: child, value: record[child.id], idPrefix: `${id}-${index}`, onChange: (nextValue) => {
1157
1493
  const nextItems = [...items];
1158
1494
  nextItems[index] = { ...record, [child.id]: nextValue };
1159
1495
  onChange(nextItems);
1160
1496
  } }, child.id))) })] }, index));
1161
- }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: "Agregar otra caja" })] }));
1497
+ }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: t('repeatAdd') })] }));
1162
1498
  }
1163
1499
  if (field.type === 'checkbox') {
1164
1500
  return (_jsxs("div", { className: "bw-field bw-field--wide bw-checkbox-field", children: [_jsxs("label", { htmlFor: id, children: [_jsx("input", { id: id, type: "checkbox", checked: value === true, onChange: (event) => onChange(event.target.checked) }), _jsxs("span", { children: [label, field.required ? ' *' : ''] })] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1165
1501
  }
1166
1502
  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] }));
1503
+ const options = (field.options ?? []).map((option) => ({
1504
+ value: option.value,
1505
+ label: (pickLocaleField(option, 'label', locale) ??
1506
+ option.labelEn ??
1507
+ option.label) ||
1508
+ option.value,
1509
+ }));
1510
+ return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx(IntakeSelect, { id: id, value: stringValue, onChange: onChange, options: options, placeholder: t('selectFieldPlaceholder'), required: field.required }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1168
1511
  }
1169
1512
  if (field.type === 'textarea') {
1170
1513
  return (_jsxs("div", { className: "bw-field bw-field--wide", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx("textarea", { id: id, rows: 3, required: field.required, value: stringValue, placeholder: field.placeholder, onChange: (event) => onChange(event.target.value) }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
@@ -1184,6 +1527,7 @@ function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, a
1184
1527
  }, children: _jsx(StripePaymentForm, { amountCents: payment.amountCents, currency: payment.currency, appointmentId: appointmentId, showInlineButton: showInlineButton, actionTarget: actionTarget, onSuccess: onSuccess, onError: onError }) }));
1185
1528
  }
1186
1529
  function StripePaymentForm({ amountCents, currency, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1530
+ const { t, locale } = useTranslation();
1187
1531
  const stripe = useStripe();
1188
1532
  const elements = useElements();
1189
1533
  const [isPaying, setIsPaying] = useState(false);
@@ -1205,34 +1549,34 @@ function StripePaymentForm({ amountCents, currency, appointmentId, showInlineBut
1205
1549
  }
1206
1550
  onSuccess();
1207
1551
  }
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] }));
1552
+ const renderPayButton = () => (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !stripe || !elements || isPaying, onClick: () => void handlePay(), children: [_jsx("span", { children: isPaying ? t('paymentProcessing') : t('paymentPayAndConfirm') }), !isPaying ? _jsx(ArrowRightIcon, {}) : null] }));
1553
+ return (_jsxs("div", { className: "bw-pay", children: [_jsx("div", { className: "bw-pay-summary", children: _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: t('paymentTotalDueToday') }), _jsx("span", { className: "bw-pay-summary-val", children: currencyFormatter(currency, localeToIntl(locale)).format(amountCents / 100) })] }) }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: t('paymentSecureLabel') }), _jsx("div", { className: "bw-pay-card-slot", children: _jsx(PaymentElement, {}) })] }), showInlineButton ? renderPayButton() : null, actionTarget ? createPortal(renderPayButton(), actionTarget) : null] }));
1210
1554
  }
1211
1555
  function ServiceWarningCallout({ warning }) {
1212
1556
  return (_jsxs("div", { className: "bw-service-warning", role: "alert", children: [_jsx("div", { className: "bw-service-warning-icon", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }), _jsxs("div", { className: "bw-service-warning-copy", children: [_jsx("div", { className: "bw-service-warning-title", children: warning.title }), _jsx("p", { children: warning.body }), warning.linkHref && warning.linkLabel ? (_jsx("a", { href: warning.linkHref, className: "bw-service-warning-link", children: warning.linkLabel })) : null] })] }));
1213
1557
  }
1214
- function formatPrice(service) {
1558
+ function formatPrice(service, locale = 'en-US') {
1215
1559
  if (!service)
1216
1560
  return '';
1217
- return formatServicePrice(service);
1561
+ return formatServicePrice(service, undefined, locale);
1218
1562
  }
1219
- function formatServicePrice(service, quote) {
1563
+ function formatServicePrice(service, quote, locale = 'en-US') {
1220
1564
  if (service.pricingMode === 'calculated') {
1221
1565
  if (quote) {
1222
- return currencyFormatter(quote.currency).format(quote.totalCents / 100);
1566
+ return currencyFormatter(quote.currency, locale).format(quote.totalCents / 100);
1223
1567
  }
1224
1568
  return 'Price calculated after details';
1225
1569
  }
1226
- return currencyFormatter(service.currency).format(service.priceCents / 100);
1570
+ return currencyFormatter(service.currency, locale).format(service.priceCents / 100);
1227
1571
  }
1228
- function getServiceWarning(service, mode) {
1572
+ function getServiceWarning(service, mode, locale = DEFAULT_LOCALE) {
1229
1573
  if (!service || mode !== 'async')
1230
1574
  return null;
1231
1575
  const warning = readRecord(service.publicCopy?.remoteWarning);
1232
1576
  if (!warning)
1233
1577
  return null;
1234
- const title = readString(warning.titleEs) ?? readString(warning.title);
1235
- const body = readString(warning.bodyEs) ?? readString(warning.body);
1578
+ const title = pickLocaleField(warning, 'title', locale);
1579
+ const body = pickLocaleField(warning, 'body', locale);
1236
1580
  if (!title || !body)
1237
1581
  return null;
1238
1582
  return {
@@ -1250,16 +1594,11 @@ function readRecord(value) {
1250
1594
  function readString(value) {
1251
1595
  return typeof value === 'string' && value.trim() ? value : null;
1252
1596
  }
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
- }
1597
+ // BookingWidgetSkeleton was a hand-built first-load ghost that
1598
+ // constantly drifted out of sync with the real layout. Replaced by
1599
+ // boneyard-js: BookingWidgetPanel wraps its main render in
1600
+ // <Skeleton name="booking-widget"> and `./bones/registry` ships
1601
+ // pre-captured bones JSON regenerated via `npx boneyard-js build`.
1263
1602
  function AvailabilitySkeleton() {
1264
1603
  // Reuses .bw-time-slots so the skeleton inherits the same layout
1265
1604
  // overrides as the real slots — vertical 1-col on desktop (via the
@@ -1273,10 +1612,10 @@ function AvailabilitySkeleton() {
1273
1612
  * the backend stores absolute ms timestamps so this renders correctly
1274
1613
  * for the host's calendar.
1275
1614
  */
1276
- function formatFormerSlot(startMs, endMs) {
1615
+ function formatFormerSlot(startMs, endMs, locale = 'en-US') {
1277
1616
  const start = new Date(startMs);
1278
1617
  const end = new Date(endMs);
1279
- const dateLabel = start.toLocaleDateString([], {
1618
+ const dateLabel = start.toLocaleDateString(locale, {
1280
1619
  weekday: 'short',
1281
1620
  month: 'short',
1282
1621
  day: 'numeric',
@@ -1285,7 +1624,7 @@ function formatFormerSlot(startMs, endMs) {
1285
1624
  hour: 'numeric',
1286
1625
  minute: '2-digit',
1287
1626
  };
1288
- return `${dateLabel} · ${start.toLocaleTimeString([], timeOpts)} – ${end.toLocaleTimeString([], timeOpts)}`;
1627
+ return `${dateLabel} · ${start.toLocaleTimeString(locale, timeOpts)} – ${end.toLocaleTimeString(locale, timeOpts)}`;
1289
1628
  }
1290
1629
  /**
1291
1630
  * Possessive form of a name. "Eric Lan" → "Eric Lan's", "James" →
@@ -1301,18 +1640,18 @@ function formatPossessive(name) {
1301
1640
  return `${trimmed}'`;
1302
1641
  return `${trimmed}'s`;
1303
1642
  }
1304
- function mobileStepTitle(step) {
1643
+ function mobileStepTitle(step, t) {
1305
1644
  switch (step) {
1306
1645
  case 1:
1307
- return 'Choose a service';
1646
+ return t('stepChooseService');
1308
1647
  case 2:
1309
- return 'Pick a provider';
1648
+ return t('stepPickDateTime');
1310
1649
  case 3:
1311
- return 'Pick a date & time';
1650
+ return t('stepYourDetails');
1312
1651
  case 4:
1313
- return 'Your details';
1652
+ return t('stepReviewConfirm');
1314
1653
  default:
1315
- return 'Booking';
1654
+ return t('stepBooking');
1316
1655
  }
1317
1656
  }
1318
1657
  function formatDuration(minutes) {
@@ -1322,8 +1661,8 @@ function formatDuration(minutes) {
1322
1661
  }
1323
1662
  return `${minutes} min`;
1324
1663
  }
1325
- function currencyFormatter(currency) {
1326
- return new Intl.NumberFormat('en-US', {
1664
+ function currencyFormatter(currency, locale = 'en-US') {
1665
+ return new Intl.NumberFormat(locale, {
1327
1666
  style: 'currency',
1328
1667
  currency: currency || 'USD',
1329
1668
  minimumFractionDigits: 0,
@@ -1356,26 +1695,28 @@ function formatDateKey(date) {
1356
1695
  const day = `${date.getDate()}`.padStart(2, '0');
1357
1696
  return `${year}-${month}-${day}`;
1358
1697
  }
1359
- function formatMonthLabel(date) {
1360
- return new Intl.DateTimeFormat('en-US', {
1361
- month: 'long',
1362
- year: 'numeric',
1363
- }).format(date);
1698
+ function formatMonthLabel(date, locale = 'en-US') {
1699
+ // Use a simple "Month YYYY" shape across locales. Default Spanish
1700
+ // formatting yields "mayo de 2026" (lowercased, with "de"), which
1701
+ // we don't want — strip both for a tighter calendar header.
1702
+ const month = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date);
1703
+ const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
1704
+ return `${capitalizedMonth} ${date.getFullYear()}`;
1364
1705
  }
1365
- function formatReadableDate(dateKey) {
1366
- return new Intl.DateTimeFormat('en-US', {
1706
+ function formatReadableDate(dateKey, locale = 'en-US') {
1707
+ return new Intl.DateTimeFormat(locale, {
1367
1708
  weekday: 'short',
1368
1709
  month: 'short',
1369
1710
  day: 'numeric',
1370
1711
  }).format(new Date(`${dateKey}T00:00:00`));
1371
1712
  }
1372
- function formatTimeLabel(time) {
1713
+ function formatTimeLabel(time, locale = 'en-US') {
1373
1714
  const [hourString, minuteString] = time.split(':');
1374
1715
  const hour = Number(hourString);
1375
1716
  const minute = Number(minuteString);
1376
1717
  const date = new Date();
1377
1718
  date.setHours(hour, minute, 0, 0);
1378
- return new Intl.DateTimeFormat('en-US', {
1719
+ return new Intl.DateTimeFormat(locale, {
1379
1720
  hour: 'numeric',
1380
1721
  minute: '2-digit',
1381
1722
  }).format(date);
@@ -1405,13 +1746,13 @@ function buildCalendarDays(month) {
1405
1746
  function sameMonth(left, right) {
1406
1747
  return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
1407
1748
  }
1408
- function getMonthOptions(currentMonth, total) {
1749
+ function getMonthOptions(currentMonth, total, locale = 'en-US') {
1409
1750
  const start = startOfMonth(new Date());
1410
1751
  return Array.from({ length: total }, (_, index) => {
1411
1752
  const date = addMonths(start, index);
1412
1753
  return {
1413
1754
  value: optionCurrentValue(date),
1414
- label: formatMonthLabel(date),
1755
+ label: formatMonthLabel(date, locale),
1415
1756
  date,
1416
1757
  isCurrent: sameMonth(date, currentMonth),
1417
1758
  };
@@ -1431,15 +1772,14 @@ function findFirstAvailableDate(availabilityByDate, month) {
1431
1772
  .sort();
1432
1773
  return dates[0] ?? null;
1433
1774
  }
1434
- async function prefetchAvailability({ client, siteSlug, selectedServiceId, selectedStaffId, calendarMonth, cacheRef, }) {
1435
- const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, selectedStaffId, calendarMonth);
1775
+ async function prefetchAvailability({ client, siteSlug, selectedServiceId, calendarMonth, cacheRef, }) {
1776
+ const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, calendarMonth);
1436
1777
  if (cacheRef.current.has(requestKey)) {
1437
1778
  return;
1438
1779
  }
1439
1780
  const result = await client.getPublicAvailableSlots({
1440
1781
  siteSlug,
1441
1782
  serviceId: selectedServiceId,
1442
- staffMemberId: selectedStaffId ?? undefined,
1443
1783
  startDate: formatDateKey(calendarMonth),
1444
1784
  endDate: formatDateKey(endOfMonth(calendarMonth)),
1445
1785
  });
@@ -1449,8 +1789,8 @@ async function prefetchAvailability({ client, siteSlug, selectedServiceId, selec
1449
1789
  }
1450
1790
  cacheRef.current.set(requestKey, nextMap);
1451
1791
  }
1452
- function getAvailabilityCacheKey(siteSlug, serviceId, staffId, month) {
1453
- return `${siteSlug}:${serviceId}:${staffId ?? 'any'}:${optionCurrentValue(month)}`;
1792
+ function getAvailabilityCacheKey(siteSlug, serviceId, month) {
1793
+ return `${siteSlug}:${serviceId}:${optionCurrentValue(month)}`;
1454
1794
  }
1455
1795
  function formatHoldCountdown(totalSeconds) {
1456
1796
  const minutes = Math.floor(totalSeconds / 60);