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