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