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