@asksable/site-connector 0.1.5 → 0.2.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/dist/booking-widget.d.ts +75 -1
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +987 -130
- package/dist/booking-widget.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +56 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/provider.d.ts +2 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/styles.css +1673 -91
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
package/dist/booking-widget.js
CHANGED
|
@@ -1,37 +1,135 @@
|
|
|
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 { createPortal } from 'react-dom';
|
|
4
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
5
|
+
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
|
3
6
|
import { useSableSiteClient, useSableSiteConfig } from './provider.js';
|
|
7
|
+
const stripePromises = new Map();
|
|
8
|
+
function getStripePromise(publishableKey, connectAccountId) {
|
|
9
|
+
const key = `${publishableKey}:${connectAccountId}`;
|
|
10
|
+
const cached = stripePromises.get(key);
|
|
11
|
+
if (cached)
|
|
12
|
+
return cached;
|
|
13
|
+
const promise = loadStripe(publishableKey, { stripeAccount: connectAccountId });
|
|
14
|
+
stripePromises.set(key, promise);
|
|
15
|
+
return promise;
|
|
16
|
+
}
|
|
17
|
+
function isDevRuntime() {
|
|
18
|
+
const env = import.meta.env;
|
|
19
|
+
return env?.DEV === true || env?.MODE === 'development';
|
|
20
|
+
}
|
|
21
|
+
function hasIntakeFormSections(service) {
|
|
22
|
+
return (service?.intakeForm?.sections?.length ?? 0) > 0;
|
|
23
|
+
}
|
|
24
|
+
function findCalculatedServiceMissingIntake(setup) {
|
|
25
|
+
return setup.services.find((service) => service.pricingMode === 'calculated' && !hasIntakeFormSections(service));
|
|
26
|
+
}
|
|
4
27
|
const MOBILE_PROGRESS_STEPS = [1, 2, 3, 4];
|
|
5
|
-
export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
28
|
+
export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
|
|
29
|
+
const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
|
|
30
|
+
const isReschedule = mode === 'reschedule';
|
|
31
|
+
// Reschedule summary holds longer values (full former-time
|
|
32
|
+
// sentence, "Signature Color Refresh") that the widget's default
|
|
33
|
+
// single-line ellipsis truncates. We override per-row inline so
|
|
34
|
+
// create mode is unchanged.
|
|
35
|
+
const summaryValStyle = isReschedule
|
|
36
|
+
? {
|
|
37
|
+
whiteSpace: 'normal',
|
|
38
|
+
overflow: 'visible',
|
|
39
|
+
textOverflow: 'clip',
|
|
40
|
+
wordBreak: 'normal',
|
|
41
|
+
overflowWrap: 'anywhere',
|
|
42
|
+
}
|
|
43
|
+
: undefined;
|
|
6
44
|
const client = useSableSiteClient();
|
|
7
45
|
const { siteSlug } = useSableSiteConfig();
|
|
8
46
|
const [setup, setSetup] = useState(null);
|
|
9
47
|
const [isSetupLoading, setIsSetupLoading] = useState(true);
|
|
10
|
-
|
|
11
|
-
|
|
48
|
+
// In reschedule mode the service is locked to the original
|
|
49
|
+
// appointment's service. We seed selection from rescheduleContext
|
|
50
|
+
// up front so the calendar/slot fetch fires without the user
|
|
51
|
+
// needing to click anything.
|
|
52
|
+
const [selectedServiceId, setSelectedServiceId] = useState(() => rescheduleContext?.serviceId ?? null);
|
|
53
|
+
const [selectedStaffId, setSelectedStaffId] = useState(() => rescheduleContext?.staffMemberId ?? null);
|
|
12
54
|
const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
|
|
13
55
|
const [availabilityByDate, setAvailabilityByDate] = useState(new Map());
|
|
14
56
|
const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
|
|
15
57
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
16
58
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
17
59
|
const [pendingSlotKey, setPendingSlotKey] = useState(null);
|
|
60
|
+
// Service picker is progressive: full list shows initially, collapses
|
|
61
|
+
// to a single "selected service" card once a service is picked, then
|
|
62
|
+
// the provider section reveals below. Click Change on the card flips
|
|
63
|
+
// back to full list. Reschedule mode skips the collapse since the
|
|
64
|
+
// service is locked from rescheduleContext at mount.
|
|
65
|
+
const [isEditingService, setIsEditingService] = useState(false);
|
|
18
66
|
const [holdId, setHoldId] = useState(null);
|
|
19
67
|
const [holdExpiresAt, setHoldExpiresAt] = useState(null);
|
|
20
68
|
const [heldStaffId, setHeldStaffId] = useState(null);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
// Pre-fill contact fields from the original booking when
|
|
70
|
+
// rescheduling. Editable, since a user might want to fix typos at
|
|
71
|
+
// the same time, but the reschedule flow doesn't actually overwrite
|
|
72
|
+
// them — the host's mutation only patches the time fields.
|
|
73
|
+
const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
|
|
74
|
+
const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
|
|
75
|
+
const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
|
|
24
76
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
77
|
+
const [intakeResponses, setIntakeResponses] = useState({});
|
|
78
|
+
const [quote, setQuote] = useState(null);
|
|
79
|
+
const [isQuoteLoading, setIsQuoteLoading] = useState(false);
|
|
80
|
+
const [payment, setPayment] = useState(null);
|
|
81
|
+
const [paymentAppointmentId, setPaymentAppointmentId] = useState(null);
|
|
25
82
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
26
83
|
const [error, setError] = useState(null);
|
|
27
84
|
const [success, setSuccess] = useState(null);
|
|
28
85
|
const [mobileStep, setMobileStep] = useState(1);
|
|
86
|
+
const [mobilePaymentActionTarget, setMobilePaymentActionTarget] = useState(null);
|
|
87
|
+
// Two-step desktop flow: 'slots' shows the available-times list,
|
|
88
|
+
// 'details' shows the customer form + summary + confirm. Picking a
|
|
89
|
+
// slot auto-advances to details with a crossfade matching Sable's
|
|
90
|
+
// popup rule (250ms expo ease-out in, 150ms ease-out out). Back
|
|
91
|
+
// button in details returns to slots and releases the hold.
|
|
92
|
+
const [viewState, setViewState] = useState('slots');
|
|
93
|
+
// Dev-only: force viewState to 'details' when previewing a payment
|
|
94
|
+
// variant, since the payment panel renders inside the details form.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (__devForceState === 'payment-full' || __devForceState === 'payment-deposit') {
|
|
97
|
+
setViewState('details');
|
|
98
|
+
}
|
|
99
|
+
}, [__devForceState]);
|
|
29
100
|
const [monthOpen, setMonthOpen] = useState(false);
|
|
30
101
|
const [staffOpen, setStaffOpen] = useState(false);
|
|
31
102
|
const [holdNow, setHoldNow] = useState(() => Date.now());
|
|
32
103
|
const [sessionToken] = useState(() => sessionTokenFromBrowser());
|
|
33
104
|
const staffMenuRef = useRef(null);
|
|
34
105
|
const availabilityCacheRef = useRef(new Map());
|
|
106
|
+
const holdIdRef = useRef(null);
|
|
107
|
+
// Calendar card lives in the middle column and is the natural
|
|
108
|
+
// height anchor of the desktop layout. We measure it on resize and
|
|
109
|
+
// expose --bw-cal-h on the .bw root so the left (services) and
|
|
110
|
+
// right (slots) columns can cap their internal scrollers to match,
|
|
111
|
+
// keeping all three columns the same visual height regardless of
|
|
112
|
+
// service-list length or slot count.
|
|
113
|
+
const calCardRef = useRef(null);
|
|
114
|
+
const widgetRootRef = useRef(null);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const card = calCardRef.current;
|
|
117
|
+
const root = widgetRootRef.current;
|
|
118
|
+
if (!card || !root)
|
|
119
|
+
return;
|
|
120
|
+
const observer = new ResizeObserver((entries) => {
|
|
121
|
+
const entry = entries[0];
|
|
122
|
+
if (!entry)
|
|
123
|
+
return;
|
|
124
|
+
const height = Math.round(entry.contentRect.height);
|
|
125
|
+
root.style.setProperty('--bw-cal-h', `${height}px`);
|
|
126
|
+
});
|
|
127
|
+
observer.observe(card);
|
|
128
|
+
return () => observer.disconnect();
|
|
129
|
+
}, [setup]);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
holdIdRef.current = holdId;
|
|
132
|
+
}, [holdId]);
|
|
35
133
|
useEffect(() => {
|
|
36
134
|
let cancelled = false;
|
|
37
135
|
setIsSetupLoading(true);
|
|
@@ -41,8 +139,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
41
139
|
if (cancelled) {
|
|
42
140
|
return;
|
|
43
141
|
}
|
|
142
|
+
const missingIntake = findCalculatedServiceMissingIntake(result);
|
|
143
|
+
if (missingIntake && isDevRuntime()) {
|
|
144
|
+
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.`);
|
|
145
|
+
}
|
|
44
146
|
setSetup(result);
|
|
45
|
-
|
|
147
|
+
// Don't auto-select a service in create mode — let the user
|
|
148
|
+
// actively pick one. Reschedule mode pre-seeds via the
|
|
149
|
+
// useState initializer above so this preserves that.
|
|
46
150
|
setError(null);
|
|
47
151
|
})
|
|
48
152
|
.catch((nextError) => {
|
|
@@ -73,6 +177,90 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
73
177
|
};
|
|
74
178
|
}, [staffOpen]);
|
|
75
179
|
const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
|
|
180
|
+
const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
|
|
181
|
+
const selectedServicePath = selectedServiceRequiresSlot ? 'scheduled' : 'async';
|
|
182
|
+
const selectedServiceHasIntakeForm = hasIntakeFormSections(selectedService);
|
|
183
|
+
const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
|
|
184
|
+
areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
|
|
185
|
+
const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath), [selectedService, selectedServicePath]);
|
|
186
|
+
const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
|
|
187
|
+
const trimmedCustomerEmail = customerEmail.trim();
|
|
188
|
+
const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
|
|
189
|
+
const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
|
|
190
|
+
// In reschedule mode we pre-select the original service but keep
|
|
191
|
+
// the full list visible — the admin may legitimately need to
|
|
192
|
+
// switch service when rescheduling (e.g. customer asked for a
|
|
193
|
+
// shorter visit alongside the time change).
|
|
194
|
+
const visibleServices = useMemo(() => {
|
|
195
|
+
if (!setup)
|
|
196
|
+
return [];
|
|
197
|
+
return setup.services;
|
|
198
|
+
}, [setup]);
|
|
199
|
+
const serviceGroups = useMemo(() => {
|
|
200
|
+
if (!setup || visibleServices.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
const categoriesById = new Map((setup.categories ?? []).map((c) => [c._id, c]));
|
|
203
|
+
const groups = new Map();
|
|
204
|
+
const uncategorizedKey = '__uncategorized__';
|
|
205
|
+
for (const service of visibleServices) {
|
|
206
|
+
const category = service.categoryId != null
|
|
207
|
+
? categoriesById.get(service.categoryId) ?? null
|
|
208
|
+
: null;
|
|
209
|
+
const key = category?._id ?? uncategorizedKey;
|
|
210
|
+
const existing = groups.get(key);
|
|
211
|
+
if (existing) {
|
|
212
|
+
existing.services.push(service);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
groups.set(key, {
|
|
216
|
+
key,
|
|
217
|
+
categoryName: category?.name ?? null,
|
|
218
|
+
services: [service],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Order: categories by their configured sortOrder, then any
|
|
223
|
+
// uncategorized bucket at the end.
|
|
224
|
+
const orderedCategoryKeys = (setup.categories ?? []).map((c) => c._id);
|
|
225
|
+
const ordered = [];
|
|
226
|
+
for (const id of orderedCategoryKeys) {
|
|
227
|
+
const g = groups.get(id);
|
|
228
|
+
if (g)
|
|
229
|
+
ordered.push(g);
|
|
230
|
+
}
|
|
231
|
+
const uncategorized = groups.get(uncategorizedKey);
|
|
232
|
+
if (uncategorized)
|
|
233
|
+
ordered.push(uncategorized);
|
|
234
|
+
// If the workspace has no categories at all, return a single
|
|
235
|
+
// group with no header so the picker still reads as one list.
|
|
236
|
+
if (ordered.length === 1 && ordered[0].categoryName === null) {
|
|
237
|
+
return ordered;
|
|
238
|
+
}
|
|
239
|
+
return ordered;
|
|
240
|
+
}, [setup, visibleServices]);
|
|
241
|
+
// Original service / staff display names (looked up against the
|
|
242
|
+
// loaded setup), used in the reschedule summary's "Former …" rows.
|
|
243
|
+
// These render only when the user changes the corresponding field
|
|
244
|
+
// away from the original — picking only a new time leaves them
|
|
245
|
+
// hidden, so the summary stays clean for the common case.
|
|
246
|
+
const formerService = useMemo(() => {
|
|
247
|
+
if (!isReschedule || !rescheduleContext || !setup)
|
|
248
|
+
return null;
|
|
249
|
+
return (setup.services.find((s) => s._id === rescheduleContext.serviceId) ?? null);
|
|
250
|
+
}, [isReschedule, rescheduleContext, setup]);
|
|
251
|
+
const formerStaffName = useMemo(() => {
|
|
252
|
+
if (!isReschedule || !rescheduleContext?.staffMemberId || !setup) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return (setup.staff.find((s) => s._id === rescheduleContext.staffMemberId)
|
|
256
|
+
?.name ?? null);
|
|
257
|
+
}, [isReschedule, rescheduleContext, setup]);
|
|
258
|
+
const serviceChanged = isReschedule &&
|
|
259
|
+
rescheduleContext != null &&
|
|
260
|
+
selectedServiceId !== rescheduleContext.serviceId;
|
|
261
|
+
const staffChanged = isReschedule &&
|
|
262
|
+
rescheduleContext != null &&
|
|
263
|
+
(selectedStaffId ?? null) !== (rescheduleContext.staffMemberId ?? null);
|
|
76
264
|
const availableStaff = useMemo(() => {
|
|
77
265
|
if (!setup || !selectedService) {
|
|
78
266
|
return [];
|
|
@@ -80,13 +268,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
80
268
|
return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
|
|
81
269
|
}, [selectedService, setup]);
|
|
82
270
|
useEffect(() => {
|
|
271
|
+
// Wait for availableStaff to populate before clearing — otherwise
|
|
272
|
+
// a pre-seeded staffMemberId (e.g. from rescheduleContext) gets
|
|
273
|
+
// wiped on the first render before the staff list arrives.
|
|
274
|
+
if (availableStaff.length === 0)
|
|
275
|
+
return;
|
|
83
276
|
if (selectedStaffId && !availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
|
|
84
277
|
setSelectedStaffId(null);
|
|
85
278
|
}
|
|
86
279
|
}, [availableStaff, selectedStaffId]);
|
|
87
280
|
const selectedStaff = useMemo(() => availableStaff.find((staffMember) => staffMember._id === selectedStaffId) ?? null, [availableStaff, selectedStaffId]);
|
|
88
281
|
useEffect(() => {
|
|
89
|
-
if (!selectedServiceId) {
|
|
282
|
+
if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
|
|
90
283
|
setAvailabilityByDate(new Map());
|
|
91
284
|
setSelectedDate(null);
|
|
92
285
|
return;
|
|
@@ -150,9 +343,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
150
343
|
return () => {
|
|
151
344
|
cancelled = true;
|
|
152
345
|
};
|
|
153
|
-
}, [calendarMonth, client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
|
|
346
|
+
}, [calendarMonth, client, selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
|
|
154
347
|
useEffect(() => {
|
|
155
|
-
if (!selectedServiceId) {
|
|
348
|
+
if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
|
|
156
349
|
return;
|
|
157
350
|
}
|
|
158
351
|
const nextMonth = addMonths(calendarMonth, 1);
|
|
@@ -164,14 +357,65 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
164
357
|
calendarMonth: nextMonth,
|
|
165
358
|
cacheRef: availabilityCacheRef,
|
|
166
359
|
});
|
|
167
|
-
}, [calendarMonth, client, selectedServiceId, selectedStaffId, siteSlug]);
|
|
360
|
+
}, [calendarMonth, client, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
|
|
168
361
|
useEffect(() => {
|
|
169
362
|
setSelectedSlot(null);
|
|
170
363
|
setPendingSlotKey(null);
|
|
171
364
|
setHeldStaffId(null);
|
|
172
365
|
setSuccess(null);
|
|
173
366
|
setError(null);
|
|
174
|
-
|
|
367
|
+
setPayment(null);
|
|
368
|
+
setPaymentAppointmentId(null);
|
|
369
|
+
if (selectedService?.publicBookingMode === 'async') {
|
|
370
|
+
setViewState('details');
|
|
371
|
+
setMobileStep(4);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
setViewState('slots');
|
|
375
|
+
}
|
|
376
|
+
}, [selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId]);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
setIntakeResponses({});
|
|
379
|
+
setQuote(null);
|
|
380
|
+
}, [selectedServiceId]);
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!selectedServiceId ||
|
|
383
|
+
selectedService?.pricingMode !== 'calculated' ||
|
|
384
|
+
!isIntakeComplete) {
|
|
385
|
+
setQuote(null);
|
|
386
|
+
setIsQuoteLoading(false);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let cancelled = false;
|
|
390
|
+
setIsQuoteLoading(true);
|
|
391
|
+
const handle = window.setTimeout(() => {
|
|
392
|
+
void client
|
|
393
|
+
.quotePublicBooking({
|
|
394
|
+
siteSlug,
|
|
395
|
+
serviceId: selectedServiceId,
|
|
396
|
+
intakeResponses,
|
|
397
|
+
})
|
|
398
|
+
.then((nextQuote) => {
|
|
399
|
+
if (!cancelled) {
|
|
400
|
+
setQuote(nextQuote);
|
|
401
|
+
setError(null);
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
.catch((nextError) => {
|
|
405
|
+
if (!cancelled) {
|
|
406
|
+
setError(nextError instanceof Error ? nextError.message : 'Unable to calculate price.');
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
.finally(() => {
|
|
410
|
+
if (!cancelled)
|
|
411
|
+
setIsQuoteLoading(false);
|
|
412
|
+
});
|
|
413
|
+
}, 300);
|
|
414
|
+
return () => {
|
|
415
|
+
cancelled = true;
|
|
416
|
+
window.clearTimeout(handle);
|
|
417
|
+
};
|
|
418
|
+
}, [client, intakeResponses, isIntakeComplete, selectedService?.pricingMode, selectedServiceId, siteSlug]);
|
|
175
419
|
useEffect(() => {
|
|
176
420
|
return () => {
|
|
177
421
|
if (!holdId) {
|
|
@@ -186,6 +430,36 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
186
430
|
.catch(() => undefined);
|
|
187
431
|
};
|
|
188
432
|
}, [client, holdId, sessionToken, siteSlug]);
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
function releaseHoldForPageLeave() {
|
|
435
|
+
const activeHoldId = holdIdRef.current;
|
|
436
|
+
if (!activeHoldId) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
client.releasePublicBookingHoldOnPageLeave({
|
|
440
|
+
siteSlug,
|
|
441
|
+
holdId: activeHoldId,
|
|
442
|
+
sessionToken,
|
|
443
|
+
});
|
|
444
|
+
holdIdRef.current = null;
|
|
445
|
+
setHoldId(null);
|
|
446
|
+
setHoldExpiresAt(null);
|
|
447
|
+
setHeldStaffId(null);
|
|
448
|
+
}
|
|
449
|
+
function handleVisibilityChange() {
|
|
450
|
+
if (document.visibilityState === 'hidden') {
|
|
451
|
+
releaseHoldForPageLeave();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
window.addEventListener('pagehide', releaseHoldForPageLeave);
|
|
455
|
+
window.addEventListener('beforeunload', releaseHoldForPageLeave);
|
|
456
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
457
|
+
return () => {
|
|
458
|
+
window.removeEventListener('pagehide', releaseHoldForPageLeave);
|
|
459
|
+
window.removeEventListener('beforeunload', releaseHoldForPageLeave);
|
|
460
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
461
|
+
};
|
|
462
|
+
}, [client, sessionToken, siteSlug]);
|
|
189
463
|
useEffect(() => {
|
|
190
464
|
if (!holdId || !holdExpiresAt) {
|
|
191
465
|
return;
|
|
@@ -230,11 +504,103 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
230
504
|
const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
|
|
231
505
|
const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
|
|
232
506
|
const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
|
|
233
|
-
|
|
234
|
-
|
|
507
|
+
// Mobile 4-step gates. canAdvanceStepN = "the user can move PAST
|
|
508
|
+
// step N to step N+1". Step 1 (service) requires a pick. Step 2
|
|
509
|
+
// (provider) is always passable since "Any provider" is the
|
|
510
|
+
// default. Step 3 (calendar) requires both date + slot.
|
|
511
|
+
const canAdvanceStep1 = Boolean(selectedService);
|
|
512
|
+
const canAdvanceStep2 = true;
|
|
235
513
|
const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
|
|
236
|
-
|
|
237
|
-
|
|
514
|
+
// Slots block is rendered in two locations: inline under the calendar
|
|
515
|
+
// (mobile flow keeps current step-3 behavior) and at the top of the
|
|
516
|
+
// right column on desktop (calendar | slots | form layout). Both
|
|
517
|
+
// locations share the same handlers and state, so picking a slot in
|
|
518
|
+
// either place updates the widget identically. CSS hides the wrong
|
|
519
|
+
// copy per breakpoint via .bw-slots-mobile / .bw-slots-desktop.
|
|
520
|
+
// Re-mount the slots wrapper whenever the inputs that affect the
|
|
521
|
+
// slot list change so the CSS enter animation fires (.bw-slots-fade
|
|
522
|
+
// + per-slot stagger). React diffs keys per parent, so reusing the
|
|
523
|
+
// same key on both the mobile and desktop render sites is fine —
|
|
524
|
+
// each parent independently remounts its child.
|
|
525
|
+
const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
|
|
526
|
+
const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: "Please select a service." })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
|
|
527
|
+
const slotKey = `${slot.time}-${slot.endTime}`;
|
|
528
|
+
const isPending = pendingSlotKey === slotKey;
|
|
529
|
+
const isActive = isPending ||
|
|
530
|
+
(selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
|
|
531
|
+
return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, style: { '--bw-slot-i': index }, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? 'Securing...' : formatTimeLabel(slot.time) }, slotKey));
|
|
532
|
+
}) })) : (_jsx("p", { className: "bw-no-slots", children: "No available times for this date." }))) : null }, slotsAreaKey));
|
|
533
|
+
const canSubmit = Boolean(selectedService &&
|
|
534
|
+
(!selectedServiceRequiresSlot || (selectedDate && selectedSlot && holdId)) &&
|
|
535
|
+
isIntakeComplete &&
|
|
536
|
+
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
537
|
+
customerName.trim() &&
|
|
538
|
+
isCustomerEmailValid) && !isSubmitting;
|
|
539
|
+
const submitBlockers = selectedService
|
|
540
|
+
? getSubmitBlockers({
|
|
541
|
+
selectedService,
|
|
542
|
+
selectedServiceRequiresSlot,
|
|
543
|
+
selectedDate,
|
|
544
|
+
selectedSlot,
|
|
545
|
+
holdId,
|
|
546
|
+
isIntakeComplete,
|
|
547
|
+
intakeResponses,
|
|
548
|
+
selectedServicePath,
|
|
549
|
+
quote,
|
|
550
|
+
customerName,
|
|
551
|
+
customerEmail,
|
|
552
|
+
})
|
|
553
|
+
: [];
|
|
554
|
+
function handleNotesInput(event) {
|
|
555
|
+
setCustomerNotes(event.currentTarget.value);
|
|
556
|
+
}
|
|
557
|
+
function renderNotesField(id, label, placeholder) {
|
|
558
|
+
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"] })] }));
|
|
559
|
+
}
|
|
560
|
+
function renderSubmitHelp() {
|
|
561
|
+
if (!selectedService || canSubmit || payment || isSubmitting)
|
|
562
|
+
return null;
|
|
563
|
+
if (submitBlockers.length === 0 && !isQuoteLoading)
|
|
564
|
+
return null;
|
|
565
|
+
const visibleBlockers = submitBlockers.slice(0, 5);
|
|
566
|
+
const hiddenCount = submitBlockers.length - visibleBlockers.length;
|
|
567
|
+
return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isQuoteLoading ? 'Calculating your price...' : 'Complete required fields to continue:' }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? _jsxs("li", { children: [hiddenCount, " more required fields"] }) : null] })) : null] }));
|
|
568
|
+
}
|
|
569
|
+
function renderContactFields(idSuffix) {
|
|
570
|
+
const nameId = `bw-name${idSuffix}`;
|
|
571
|
+
const emailId = `bw-email${idSuffix}`;
|
|
572
|
+
const phoneId = `bw-phone${idSuffix}`;
|
|
573
|
+
const emailErrorId = `${emailId}-error`;
|
|
574
|
+
return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: ["Full name ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "John Doe" })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: ["Email ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: "jane@example.com" }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: "Enter a valid email address." })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: "Phone" }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "+1 (555) 000-0000" })] })] }));
|
|
575
|
+
}
|
|
576
|
+
function renderIntakeFields(idPrefix) {
|
|
577
|
+
return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
|
|
578
|
+
}
|
|
579
|
+
function renderIntakeAndContact(idPrefix, idSuffix) {
|
|
580
|
+
const intakeFields = renderIntakeFields(idPrefix);
|
|
581
|
+
const contactFields = renderContactFields(idSuffix);
|
|
582
|
+
return selectedServiceHasIntakeForm ? (_jsxs(_Fragment, { children: [intakeFields, contactFields] })) : (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
|
|
583
|
+
}
|
|
584
|
+
function handleServiceSelect(service) {
|
|
585
|
+
if (holdId) {
|
|
586
|
+
void client
|
|
587
|
+
.releasePublicBookingHold({ siteSlug, holdId, sessionToken })
|
|
588
|
+
.catch(() => undefined);
|
|
589
|
+
}
|
|
590
|
+
setSelectedServiceId(service._id);
|
|
591
|
+
setIsEditingService(false);
|
|
592
|
+
setPayment(null);
|
|
593
|
+
setPaymentAppointmentId(null);
|
|
594
|
+
if (service.publicBookingMode === 'async') {
|
|
595
|
+
setSelectedDate(null);
|
|
596
|
+
setSelectedSlot(null);
|
|
597
|
+
setHoldId(null);
|
|
598
|
+
setHoldExpiresAt(null);
|
|
599
|
+
setHeldStaffId(null);
|
|
600
|
+
setViewState('details');
|
|
601
|
+
setMobileStep(4);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
238
604
|
async function handleSlotSelect(slot) {
|
|
239
605
|
if (!selectedServiceId || !selectedDate) {
|
|
240
606
|
return;
|
|
@@ -279,6 +645,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
279
645
|
setHoldExpiresAt(result.expiresAt);
|
|
280
646
|
setHeldStaffId(reservationStaffId);
|
|
281
647
|
setError(null);
|
|
648
|
+
// Auto-advance the desktop right pane to the details/form view
|
|
649
|
+
// once the hold is reserved. Mobile flow keeps the existing
|
|
650
|
+
// step-based navigation; this state change is harmless there
|
|
651
|
+
// because mobile CSS forces the details pane visible regardless.
|
|
652
|
+
setViewState('details');
|
|
282
653
|
}
|
|
283
654
|
catch (nextError) {
|
|
284
655
|
setSelectedSlot(previousSlot);
|
|
@@ -289,28 +660,96 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
289
660
|
setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
|
|
290
661
|
}
|
|
291
662
|
}
|
|
663
|
+
async function handleBackToSlots() {
|
|
664
|
+
if (holdId) {
|
|
665
|
+
await client
|
|
666
|
+
.releasePublicBookingHold({ siteSlug, holdId, sessionToken })
|
|
667
|
+
.catch(() => undefined);
|
|
668
|
+
}
|
|
669
|
+
setSelectedSlot(null);
|
|
670
|
+
setHoldId(null);
|
|
671
|
+
setHoldExpiresAt(null);
|
|
672
|
+
setHeldStaffId(null);
|
|
673
|
+
setPendingSlotKey(null);
|
|
674
|
+
setViewState('slots');
|
|
675
|
+
}
|
|
676
|
+
function handleBackToServicePicker() {
|
|
677
|
+
setSelectedServiceId(null);
|
|
678
|
+
setSelectedDate(null);
|
|
679
|
+
setSelectedSlot(null);
|
|
680
|
+
setHoldId(null);
|
|
681
|
+
setHoldExpiresAt(null);
|
|
682
|
+
setHeldStaffId(null);
|
|
683
|
+
setPendingSlotKey(null);
|
|
684
|
+
setIsEditingService(false);
|
|
685
|
+
setPayment(null);
|
|
686
|
+
setPaymentAppointmentId(null);
|
|
687
|
+
setViewState('slots');
|
|
688
|
+
setMobileStep(1);
|
|
689
|
+
}
|
|
292
690
|
async function handleConfirmBooking() {
|
|
293
|
-
if (!selectedServiceId ||
|
|
691
|
+
if (!selectedServiceId ||
|
|
692
|
+
!selectedService ||
|
|
693
|
+
(selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId))) {
|
|
294
694
|
return;
|
|
295
695
|
}
|
|
296
696
|
setIsSubmitting(true);
|
|
297
697
|
setError(null);
|
|
698
|
+
const newStartTime = selectedDate && selectedSlot
|
|
699
|
+
? Date.parse(`${selectedDate}T${selectedSlot.time}:00`)
|
|
700
|
+
: Date.now();
|
|
701
|
+
const newEndTime = selectedDate && selectedSlot
|
|
702
|
+
? Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`)
|
|
703
|
+
: newStartTime + selectedService.durationMinutes * 60 * 1000;
|
|
704
|
+
// Reschedule mode bypasses the public-create flow entirely.
|
|
705
|
+
// Service / staff / customer are already attached to the
|
|
706
|
+
// original appointment, so the host (admin path) or magic-link
|
|
707
|
+
// signed customer (public path) only needs to dispatch the new
|
|
708
|
+
// start/end. The hold is released as a courtesy since the slot
|
|
709
|
+
// is being committed via a different mutation that re-runs the
|
|
710
|
+
// conflict check itself.
|
|
711
|
+
if (isReschedule && onRescheduleSubmit) {
|
|
712
|
+
try {
|
|
713
|
+
await onRescheduleSubmit({ newStartTime, newEndTime });
|
|
714
|
+
setSuccess('Reschedule confirmed.');
|
|
715
|
+
setMobileStep(4);
|
|
716
|
+
}
|
|
717
|
+
catch (nextError) {
|
|
718
|
+
setError(nextError instanceof Error
|
|
719
|
+
? nextError.message
|
|
720
|
+
: 'Unable to reschedule. Please try again.');
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
setIsSubmitting(false);
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
298
727
|
try {
|
|
299
|
-
await client.createPublicBooking({
|
|
728
|
+
const created = await client.createPublicBooking({
|
|
300
729
|
siteSlug,
|
|
301
730
|
serviceId: selectedServiceId,
|
|
302
|
-
staffMemberId:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
731
|
+
staffMemberId: selectedServiceRequiresSlot
|
|
732
|
+
? heldStaffId ?? selectedStaffId ?? selectedSlot?.availableStaffIds[0]
|
|
733
|
+
: undefined,
|
|
734
|
+
startTime: newStartTime,
|
|
735
|
+
endTime: newEndTime,
|
|
736
|
+
timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? setup?.workspaceTimezone ?? 'UTC',
|
|
306
737
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
307
738
|
customerName: customerName.trim(),
|
|
308
739
|
customerEmail: customerEmail.trim(),
|
|
309
740
|
customerPhone: customerPhone.trim() || undefined,
|
|
310
741
|
customerNotes: customerNotes.trim() || undefined,
|
|
311
|
-
|
|
312
|
-
|
|
742
|
+
intakeResponses,
|
|
743
|
+
quotedTotalCents: quote?.totalCents,
|
|
744
|
+
bookingHoldId: selectedServiceRequiresSlot ? holdId : undefined,
|
|
745
|
+
bookingSessionToken: selectedServiceRequiresSlot ? sessionToken : undefined,
|
|
313
746
|
});
|
|
747
|
+
if (created.payment) {
|
|
748
|
+
setPayment(created.payment);
|
|
749
|
+
setPaymentAppointmentId(created.appointmentId);
|
|
750
|
+
setError(null);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
314
753
|
setSuccess('Booking confirmed.');
|
|
315
754
|
setSelectedSlot(null);
|
|
316
755
|
setPendingSlotKey(null);
|
|
@@ -321,6 +760,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
321
760
|
setCustomerEmail('');
|
|
322
761
|
setCustomerPhone('');
|
|
323
762
|
setCustomerNotes('');
|
|
763
|
+
setIntakeResponses({});
|
|
764
|
+
setQuote(null);
|
|
765
|
+
setPayment(null);
|
|
766
|
+
setPaymentAppointmentId(null);
|
|
324
767
|
setMobileStep(4);
|
|
325
768
|
}
|
|
326
769
|
catch (nextError) {
|
|
@@ -336,133 +779,534 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
336
779
|
if (!setup || setup.services.length === 0) {
|
|
337
780
|
return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
|
|
338
781
|
}
|
|
339
|
-
if (
|
|
340
|
-
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
782
|
+
if (forcedState === 'cancelled') {
|
|
783
|
+
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon bw-done-icon--muted", children: _jsx(XIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: "Booking cancelled" }), _jsx("p", { className: "bw-done-text", children: "Your appointment has been cancelled. We've let your provider know. You can book a new visit any time." }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
|
|
784
|
+
/* dev preview only — host wires the real handler */
|
|
785
|
+
}, children: "Book a new visit" })] }) }));
|
|
786
|
+
}
|
|
787
|
+
if (success || forcedState === 'success') {
|
|
788
|
+
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(CheckIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: isReschedule ? 'All set.' : "You're All Set!" }), _jsx("p", { className: "bw-done-text", children: isReschedule
|
|
789
|
+
? "Your appointment has been rescheduled. We've sent a confirmation to the email on file and notified your provider."
|
|
790
|
+
: "Your booking request has been submitted. We'll follow up using the email you provided if anything needs confirmation." }), isReschedule ? null : (_jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
|
|
791
|
+
window.location.assign(successRedirectHref);
|
|
792
|
+
}, children: "Back to home" }))] }) }));
|
|
793
|
+
}
|
|
794
|
+
return (_jsxs("section", { className: "bw", "data-view-state": viewState, ref: widgetRootRef, children: [mobileHeader ? _jsx("div", { className: "bw-mobile-header", children: mobileHeader }) : null, _jsxs("div", { className: "bw-header", children: [_jsxs("div", { className: "bw-header-row", children: [_jsx("h2", { className: "bw-title bw-title--desktop", children: title ||
|
|
795
|
+
(isReschedule
|
|
796
|
+
? rescheduleContext?.customerName
|
|
797
|
+
? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
|
|
798
|
+
: 'Reschedule appointment'
|
|
799
|
+
: 'Schedule your visit') }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
|
|
800
|
+
? rescheduleContext?.customerName
|
|
801
|
+
? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
|
|
802
|
+
: 'Reschedule appointment'
|
|
803
|
+
: mobileStepTitle(mobileStep) }), _jsx("div", { className: "bw-progress", "aria-hidden": "true", children: MOBILE_PROGRESS_STEPS.map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsxs("div", { className: "bw-content", children: [_jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("div", { className: "bw-service-picker", children: selectedService && !isEditingService ? (_jsxs("div", { className: "bw-svc-selected", children: [_jsxs("div", { className: "bw-svc-selected-header", children: [_jsx("label", { className: "bw-label", children: "Selected service" }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: "Change" })] }), _jsxs("div", { className: "bw-svc-row bw-svc-row--readonly", children: [selectedService.image ? (_jsxs("span", { className: "bw-svc-image is-checked", children: [_jsx("img", { src: selectedService.image.url, alt: selectedService.image.alt ?? selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: selectedService.name }), selectedService.description ? (_jsx("span", { className: "bw-svc-desc", children: selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService) })] })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { className: "bw-label", children: "Select Service" }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
|
|
804
|
+
const isActive = selectedServiceId === service._id;
|
|
805
|
+
return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
|
|
806
|
+
void prefetchAvailability({
|
|
807
|
+
client,
|
|
808
|
+
siteSlug,
|
|
809
|
+
selectedServiceId: service._id,
|
|
810
|
+
selectedStaffId,
|
|
811
|
+
calendarMonth,
|
|
812
|
+
cacheRef: availabilityCacheRef,
|
|
813
|
+
});
|
|
814
|
+
}, onFocus: () => {
|
|
815
|
+
void prefetchAvailability({
|
|
816
|
+
client,
|
|
817
|
+
siteSlug,
|
|
818
|
+
selectedServiceId: service._id,
|
|
819
|
+
selectedStaffId,
|
|
820
|
+
calendarMonth,
|
|
821
|
+
cacheRef: availabilityCacheRef,
|
|
822
|
+
});
|
|
823
|
+
}, onClick: () => {
|
|
824
|
+
handleServiceSelect(service);
|
|
825
|
+
}, children: [service.image ? (_jsxs("span", { className: `bw-svc-image${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service) })] })] })] }, service._id));
|
|
826
|
+
})] }, group.key))) }) }) })] })) }), selectedService && selectedServiceRequiresSlot && !isEditingService ? (_jsxs("div", { className: "bw-provider-section", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: "Select a Provider" }), _jsxs("div", { className: "bw-staff-dropdown", ref: staffMenuRef, children: [_jsxs("button", { type: "button", className: `bw-staff-trigger${selectedStaff ? ' has-value' : ''}`, onClick: () => setStaffOpen((current) => !current), children: [_jsxs("span", { className: "bw-staff-trigger-content", children: [_jsx("span", { className: "bw-staff-avatar", children: selectedStaff?.image?.url ? (_jsx("img", { src: selectedStaff.image.url, alt: selectedStaff.image.alt ?? selectedStaff.name, className: "bw-staff-avatar-img" })) : selectedStaff ? (_jsx("span", { className: "bw-staff-initials", children: getInitials(selectedStaff.name) })) : (_jsx(UserIcon, {})) }), _jsxs("span", { className: "bw-staff-trigger-info", children: [_jsx("span", { className: "bw-staff-trigger-name", children: selectedStaff ? selectedStaff.name : 'Any provider' }), selectedStaff ? null : (_jsx("span", { className: "bw-staff-trigger-desc", children: "First available" }))] })] }), _jsx("span", { className: "bw-staff-trigger-chevron", children: _jsx(ChevronDownIcon, {}) })] }), staffOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-staff-overlay", "aria-label": "Close provider menu", onClick: () => setStaffOpen(false) }), _jsxs("div", { className: "bw-staff-list", children: [_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => {
|
|
827
|
+
setSelectedStaffId(null);
|
|
828
|
+
setStaffOpen(false);
|
|
829
|
+
}, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx(UserIcon, {}) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: "Any Available" }), _jsx("span", { className: "bw-staff-option-desc", children: "First available" })] })] }), availableStaff.map((staffMember) => (_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === staffMember._id ? ' is-active' : ''}`, onClick: () => {
|
|
830
|
+
setSelectedStaffId(staffMember._id);
|
|
831
|
+
setStaffOpen(false);
|
|
832
|
+
}, children: [_jsx("span", { className: "bw-staff-avatar", children: staffMember.image?.url ? (_jsx("img", { src: staffMember.image.url, alt: staffMember.image.alt ?? staffMember.name, className: "bw-staff-avatar-img" })) : (_jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) })) }), _jsx("span", { className: "bw-staff-option-info", children: _jsx("span", { className: "bw-staff-option-name", children: staffMember.name }) })] }, staffMember._id)))] })] })) : null] })] })) : null] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: "Choose your service" }), _jsx("div", { className: "bw-step-2-divider" }), serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
|
|
833
|
+
const isActive = selectedServiceId === service._id;
|
|
834
|
+
return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
|
|
835
|
+
void prefetchAvailability({
|
|
836
|
+
client,
|
|
837
|
+
siteSlug,
|
|
838
|
+
selectedServiceId: service._id,
|
|
839
|
+
selectedStaffId,
|
|
840
|
+
calendarMonth,
|
|
841
|
+
cacheRef: availabilityCacheRef,
|
|
842
|
+
});
|
|
843
|
+
}, onFocus: () => {
|
|
844
|
+
void prefetchAvailability({
|
|
845
|
+
client,
|
|
846
|
+
siteSlug,
|
|
847
|
+
selectedServiceId: service._id,
|
|
848
|
+
selectedStaffId,
|
|
849
|
+
calendarMonth,
|
|
850
|
+
cacheRef: availabilityCacheRef,
|
|
851
|
+
});
|
|
852
|
+
}, onClick: () => {
|
|
853
|
+
handleServiceSelect(service);
|
|
854
|
+
}, children: [service.image ? (_jsxs("span", { className: `bw-svc-image bw-svc-image--lg${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check bw-check--lg${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service) })] })] })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
|
|
855
|
+
})] }, group.key)))] })] }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card", ref: calCardRef, children: [_jsxs("div", { className: "bw-cal-header", children: [_jsxs("div", { className: "bw-month-dropdown", children: [_jsxs("button", { type: "button", className: "bw-month-btn", onClick: () => setMonthOpen((current) => !current), children: [_jsx("span", { children: formatMonthLabel(calendarMonth) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": "Close month picker", onClick: () => setMonthOpen(false) }), _jsx("div", { className: "bw-month-list", children: monthOptions.map((option) => (_jsx("button", { type: "button", className: `bw-month-option${option.value === optionCurrentValue(calendarMonth) ? ' is-active' : ''}`, onClick: () => {
|
|
856
|
+
setCalendarMonth(option.date);
|
|
857
|
+
setMonthOpen(false);
|
|
858
|
+
}, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
|
|
859
|
+
if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
|
|
354
860
|
void prefetchAvailability({
|
|
355
861
|
client,
|
|
356
862
|
siteSlug,
|
|
357
|
-
selectedServiceId
|
|
863
|
+
selectedServiceId,
|
|
358
864
|
selectedStaffId,
|
|
359
|
-
calendarMonth,
|
|
865
|
+
calendarMonth: addMonths(calendarMonth, -1),
|
|
360
866
|
cacheRef: availabilityCacheRef,
|
|
361
867
|
});
|
|
362
|
-
}
|
|
868
|
+
}
|
|
869
|
+
}, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": "Previous month", children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
|
|
870
|
+
if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
|
|
363
871
|
void prefetchAvailability({
|
|
364
872
|
client,
|
|
365
873
|
siteSlug,
|
|
366
|
-
selectedServiceId
|
|
874
|
+
selectedServiceId,
|
|
367
875
|
selectedStaffId,
|
|
368
|
-
calendarMonth,
|
|
876
|
+
calendarMonth: addMonths(calendarMonth, 1),
|
|
369
877
|
cacheRef: availabilityCacheRef,
|
|
370
878
|
});
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
}, children:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
879
|
+
}
|
|
880
|
+
}, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": "Next month", children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
|
|
881
|
+
const dateKey = formatDateKey(day);
|
|
882
|
+
const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
|
|
883
|
+
const isPast = dateKey < formatDateKey(startOfDay(new Date()));
|
|
884
|
+
const slots = availabilityByDate.get(dateKey) ?? [];
|
|
885
|
+
const isAvailable = slots.length > 0;
|
|
886
|
+
const isSelected = selectedDate === dateKey;
|
|
887
|
+
const className = [
|
|
888
|
+
'bw-cal-day',
|
|
889
|
+
!isCurrentMonth ? 'is-outside' : '',
|
|
890
|
+
isSelected ? 'is-selected' : '',
|
|
891
|
+
isAvailable && !isPast ? 'is-available' : '',
|
|
892
|
+
isPast ? 'is-disabled' : '',
|
|
893
|
+
!isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
|
|
894
|
+
dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
|
|
895
|
+
]
|
|
896
|
+
.filter(Boolean)
|
|
897
|
+
.join(' ');
|
|
898
|
+
return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
|
|
899
|
+
setSelectedDate(dateKey);
|
|
900
|
+
setSelectedSlot(null);
|
|
901
|
+
setMobileStep((current) => (current < 3 ? 3 : current));
|
|
902
|
+
}, children: day.getDate() }, dateKey));
|
|
903
|
+
}) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: "Please select a service to see availability." })) : null, _jsxs("div", { className: "bw-slots-mobile", children: [selectedService && selectedDate && selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-divider" })) : null, slotsArea] }), _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] })] }) }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsx("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: _jsxs("div", { className: "bw-slots-desktop", children: [_jsxs("div", { className: "bw-slots-heading", children: [_jsx("span", { children: "Available times" }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), slotsArea] }) }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', 'Additional notes', 'Any preferences or special requests...'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? 'Reschedule Summary' : 'Booking Summary' }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
904
|
+
...summaryValStyle,
|
|
905
|
+
textDecoration: 'line-through',
|
|
906
|
+
opacity: 0.55,
|
|
907
|
+
}, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
908
|
+
...summaryValStyle,
|
|
909
|
+
textDecoration: 'line-through',
|
|
910
|
+
opacity: 0.55,
|
|
911
|
+
}, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
912
|
+
...summaryValStyle,
|
|
913
|
+
textDecoration: 'line-through',
|
|
914
|
+
opacity: 0.55,
|
|
915
|
+
}, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
|
|
916
|
+
setSuccess('Payment received. Your booking is being confirmed.');
|
|
917
|
+
setPayment(null);
|
|
918
|
+
}, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
|
|
919
|
+
? isReschedule
|
|
920
|
+
? 'Rescheduling...'
|
|
921
|
+
: 'Booking...'
|
|
922
|
+
: isReschedule
|
|
923
|
+
? 'Confirm Reschedule'
|
|
924
|
+
: selectedServiceRequiresPayment
|
|
925
|
+
? 'Continue to payment'
|
|
926
|
+
: 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-details-view", children: [_jsx("div", { className: "bw-details-summary", children: selectedService ? (_jsxs(_Fragment, { children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsx("h3", { className: "bw-details-service", children: selectedService.name }), selectedService.description ? (_jsx("p", { className: "bw-details-desc", children: selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: "Former time" }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })] })) : null, selectedDate && selectedSlot ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isReschedule ? 'New time' : 'When' }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate), _jsx("br", {}), formatTimeLabel(selectedSlot.time), " \u2013 ", formatTimeLabel(selectedSlot.endTime)] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, quote) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: "Reservation hold" }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? 'Reason for reschedule' : 'Additional notes', isReschedule
|
|
927
|
+
? 'Let your provider know why...'
|
|
928
|
+
: 'Any preferences or special requests...'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment && (forcedState === 'payment-full' || forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
|
|
929
|
+
setSuccess('Payment received. Your booking is being confirmed.');
|
|
930
|
+
setPayment(null);
|
|
931
|
+
}, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
|
|
932
|
+
? isReschedule
|
|
933
|
+
? 'Rescheduling...'
|
|
934
|
+
: 'Booking...'
|
|
935
|
+
: forcedState === 'payment-full'
|
|
936
|
+
? `Pay ${formatPrice(selectedService)} & Confirm`
|
|
937
|
+
: forcedState === 'payment-deposit'
|
|
938
|
+
? `Pay deposit & Confirm`
|
|
939
|
+
: isReschedule
|
|
940
|
+
? 'Confirm Reschedule'
|
|
941
|
+
: selectedServiceRequiresPayment
|
|
942
|
+
? 'Continue to payment'
|
|
943
|
+
: 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] })] }), _jsxs("div", { className: "bw-footer", children: [mobileStep === 4 && selectedService ? (_jsxs("div", { className: "bw-footer-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? 'Reschedule Summary' : 'Booking Summary' }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
944
|
+
...summaryValStyle,
|
|
945
|
+
textDecoration: 'line-through',
|
|
946
|
+
opacity: 0.55,
|
|
947
|
+
}, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
948
|
+
...summaryValStyle,
|
|
949
|
+
textDecoration: 'line-through',
|
|
950
|
+
opacity: 0.55,
|
|
951
|
+
}, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
|
|
952
|
+
...summaryValStyle,
|
|
953
|
+
textDecoration: 'line-through',
|
|
954
|
+
opacity: 0.55,
|
|
955
|
+
}, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => {
|
|
956
|
+
if (!selectedServiceRequiresSlot && mobileStep === 4) {
|
|
957
|
+
handleBackToServicePicker();
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
setMobileStep((step) => Math.max(1, step - 1));
|
|
961
|
+
}
|
|
962
|
+
}, children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
|
|
451
963
|
(mobileStep === 2 && !canAdvanceStep2) ||
|
|
452
|
-
(mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
|
|
964
|
+
(mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
|
|
965
|
+
? isReschedule
|
|
966
|
+
? 'Rescheduling...'
|
|
967
|
+
: 'Booking...'
|
|
968
|
+
: isReschedule
|
|
969
|
+
? 'Confirm Reschedule'
|
|
970
|
+
: selectedServiceRequiresPayment
|
|
971
|
+
? 'Continue to payment'
|
|
972
|
+
: 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
|
|
973
|
+
}
|
|
974
|
+
/* Payment panel — design proposal for the pay-before-booking flow.
|
|
975
|
+
* Renders inside the desktop details form (and mobile step-4 form
|
|
976
|
+
* once wired) when the selected service has paymentMode === 'full'
|
|
977
|
+
* or 'deposit'. Visuals only today: real implementation will mount
|
|
978
|
+
* a Stripe PaymentElement inside .bw-pay-card-slot and gate the
|
|
979
|
+
* Confirm CTA on a successful charge. Deposit mode shows the split
|
|
980
|
+
* (charged today vs at-visit) so the customer sees what's actually
|
|
981
|
+
* coming out of their card now.
|
|
982
|
+
*
|
|
983
|
+
* TODO(eric): Eric is adding a Stripe widget to the LEFT side of
|
|
984
|
+
* the details view (alongside the summary, not the form). When you
|
|
985
|
+
* pick this up:
|
|
986
|
+
* 1. Move/replace this PaymentPanel into the left summary pane
|
|
987
|
+
* (.bw-details-summary) so the Stripe Element sits where the
|
|
988
|
+
* customer reads what they're paying for. The right form pane
|
|
989
|
+
* stays as-is — name/email/phone/notes only.
|
|
990
|
+
* 2. Backend gap: getPublicBookingSetup currently strips the
|
|
991
|
+
* payment fields. Surface paymentMode, depositCents,
|
|
992
|
+
* requiresPayment, and the workspace's Stripe Connect account
|
|
993
|
+
* handle (so the PaymentElement can be configured against the
|
|
994
|
+
* right tenant) in the public response + the
|
|
995
|
+
* PublicBookingSetup type.
|
|
996
|
+
* 3. Mount Elements provider scoped to the widget; create a
|
|
997
|
+
* PaymentIntent on hold success (server-side mutation pinned
|
|
998
|
+
* to the bookingHoldId so the amount can't be tampered with).
|
|
999
|
+
* 4. Gate createPublicAppointment behind paymentIntent.status
|
|
1000
|
+
* === 'succeeded'. On failure: keep the hold for one retry.
|
|
1001
|
+
* 5. Confirm CTA copy already branches on forcedState — wire the
|
|
1002
|
+
* same branch on the real `service.requiresPayment` field.
|
|
1003
|
+
* 6. Visual reference: this PaymentPanel + the dev preview pills
|
|
1004
|
+
* ('Pay full' / 'Pay deposit') in dev/booking-widget-preview
|
|
1005
|
+
* show the design intent for the summary card layout. Reuse
|
|
1006
|
+
* the .bw-pay-summary chrome on the left side; the
|
|
1007
|
+
* .bw-pay-card-slot is what gets replaced by <PaymentElement />.
|
|
1008
|
+
*/
|
|
1009
|
+
function PaymentPanel({ service, mode, }) {
|
|
1010
|
+
if (!service)
|
|
1011
|
+
return null;
|
|
1012
|
+
const formatter = currencyFormatter(service.currency);
|
|
1013
|
+
const total = service.priceCents / 100;
|
|
1014
|
+
// Deposit defaults to 30% of total when no explicit depositCents
|
|
1015
|
+
// is set on the service. Real flow reads `service.depositCents`.
|
|
1016
|
+
const depositToday = mode === 'full' ? total : Math.round(total * 0.3 * 100) / 100;
|
|
1017
|
+
const dueAtVisit = mode === 'full' ? 0 : Math.round((total - depositToday) * 100) / 100;
|
|
1018
|
+
return (_jsxs("div", { className: "bw-pay", children: [_jsxs("div", { className: "bw-pay-summary", children: [_jsx("div", { className: "bw-pay-summary-header", children: "Summary" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: service.name }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: "Subtotal" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: "Tax" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(0) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--total", children: [_jsx("span", { children: "Total" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: "Charged today" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(depositToday) })] }), mode === 'deposit' ? (_jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: "Due at visit" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(dueAtVisit) })] })) : null] }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: "Card details" }), _jsx("div", { className: "bw-pay-card-slot", children: _jsxs("div", { className: "bw-pay-card-stub", children: [_jsxs("div", { className: "bw-pay-card-stub-row", children: [_jsx("span", { className: "bw-pay-card-stub-label", children: "Card number" }), _jsx("span", { className: "bw-pay-card-stub-value", children: "1234 1234 1234 1234" })] }), _jsxs("div", { className: "bw-pay-card-stub-grid", children: [_jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "MM / YY" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "CVC" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "ZIP" }) })] })] }) }), _jsxs("p", { className: "bw-pay-secure", children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDD12" }), " Payments are processed securely by Stripe."] })] })] }));
|
|
1019
|
+
}
|
|
1020
|
+
function IntakeFormFields({ form, responses, onChange, idPrefix, mode, }) {
|
|
1021
|
+
if (!form || form.sections.length === 0)
|
|
1022
|
+
return null;
|
|
1023
|
+
return (_jsx("div", { className: "bw-intake", children: form.sections
|
|
1024
|
+
.filter((section) => !section.showWhenPath || section.showWhenPath === mode)
|
|
1025
|
+
.map((section) => (_jsxs("div", { className: "bw-intake-section", children: [_jsx("div", { className: "bw-intake-title", children: section.titleEs ?? section.title ?? section.titleEn ?? section.id }), _jsx("div", { className: "bw-form-fields", children: section.fields.map((field) => (_jsx(IntakeField, { field: field, value: responses[field.id], onChange: (value) => onChange(field.id, value), idPrefix: `${idPrefix}-${section.id}` }, field.id))) })] }, section.id))) }));
|
|
1026
|
+
}
|
|
1027
|
+
function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
1028
|
+
return form.sections
|
|
1029
|
+
.filter((section) => !section.showWhenPath || section.showWhenPath === mode)
|
|
1030
|
+
.every((section) => section.fields.every((field) => {
|
|
1031
|
+
const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
|
|
1032
|
+
? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
|
|
1033
|
+
: undefined;
|
|
1034
|
+
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1035
|
+
}));
|
|
1036
|
+
}
|
|
1037
|
+
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, }) {
|
|
1038
|
+
const blockers = [];
|
|
1039
|
+
if (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId)) {
|
|
1040
|
+
blockers.push('Date and time');
|
|
1041
|
+
}
|
|
1042
|
+
if (!customerName.trim())
|
|
1043
|
+
blockers.push('Contact: Full name');
|
|
1044
|
+
if (!customerEmail.trim()) {
|
|
1045
|
+
blockers.push('Contact: Email');
|
|
1046
|
+
}
|
|
1047
|
+
else if (!isValidEmailAddress(customerEmail)) {
|
|
1048
|
+
blockers.push('Contact: Enter a valid email address');
|
|
1049
|
+
}
|
|
1050
|
+
if (!isIntakeComplete && selectedService.intakeForm) {
|
|
1051
|
+
blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath));
|
|
1052
|
+
}
|
|
1053
|
+
if (selectedService.pricingMode === 'calculated' &&
|
|
1054
|
+
isIntakeComplete &&
|
|
1055
|
+
!quote) {
|
|
1056
|
+
blockers.push('Calculated price');
|
|
1057
|
+
}
|
|
1058
|
+
return Array.from(new Set(blockers));
|
|
1059
|
+
}
|
|
1060
|
+
function isValidEmailAddress(value) {
|
|
1061
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
1062
|
+
}
|
|
1063
|
+
function getMissingRequiredIntakeLabels(form, responses, mode) {
|
|
1064
|
+
const missing = [];
|
|
1065
|
+
for (const section of form.sections) {
|
|
1066
|
+
if (section.showWhenPath && section.showWhenPath !== mode)
|
|
1067
|
+
continue;
|
|
1068
|
+
const sectionLabel = getIntakeSectionLabel(section);
|
|
1069
|
+
for (const field of section.fields) {
|
|
1070
|
+
const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
|
|
1071
|
+
? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
|
|
1072
|
+
: undefined;
|
|
1073
|
+
collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, minItems);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return missing;
|
|
1077
|
+
}
|
|
1078
|
+
function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, minItems) {
|
|
1079
|
+
if (!field.required)
|
|
1080
|
+
return;
|
|
1081
|
+
if (field.type !== 'repeatable-group') {
|
|
1082
|
+
if (!isRequiredIntakeFieldComplete(field, value)) {
|
|
1083
|
+
missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field)}`);
|
|
1084
|
+
}
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const fieldLabel = getIntakeFieldLabel(field);
|
|
1088
|
+
const items = Array.isArray(value) ? value : [];
|
|
1089
|
+
const requiredItemCount = Math.max(minItems ?? 1, items.length || 1);
|
|
1090
|
+
if (items.length < requiredItemCount) {
|
|
1091
|
+
missing.push(`${sectionLabel}: ${fieldLabel}`);
|
|
1092
|
+
}
|
|
1093
|
+
for (let index = 0; index < requiredItemCount; index += 1) {
|
|
1094
|
+
const item = items[index];
|
|
1095
|
+
const record = item && typeof item === 'object' && !Array.isArray(item)
|
|
1096
|
+
? item
|
|
1097
|
+
: {};
|
|
1098
|
+
for (const child of field.fields ?? []) {
|
|
1099
|
+
if (!child.required)
|
|
1100
|
+
continue;
|
|
1101
|
+
if (!isRequiredIntakeFieldComplete(child, record[child.id])) {
|
|
1102
|
+
missing.push(`${sectionLabel}: Caja ${index + 1} ${getIntakeFieldLabel(child)}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function getIntakeSectionLabel(section) {
|
|
1108
|
+
return section.titleEs ?? section.title ?? section.titleEn ?? section.id;
|
|
1109
|
+
}
|
|
1110
|
+
function getIntakeFieldLabel(field) {
|
|
1111
|
+
return field.labelEs ?? field.label ?? field.labelEn ?? field.id;
|
|
1112
|
+
}
|
|
1113
|
+
function isRequiredIntakeFieldComplete(field, value, minItems) {
|
|
1114
|
+
if (!field.required)
|
|
1115
|
+
return true;
|
|
1116
|
+
if (field.type === 'checkbox') {
|
|
1117
|
+
return value === true;
|
|
1118
|
+
}
|
|
1119
|
+
if (field.type === 'repeatable-group') {
|
|
1120
|
+
if (!Array.isArray(value) || value.length < (minItems ?? 1))
|
|
1121
|
+
return false;
|
|
1122
|
+
return value.every((item) => {
|
|
1123
|
+
const record = item && typeof item === 'object' && !Array.isArray(item)
|
|
1124
|
+
? item
|
|
1125
|
+
: {};
|
|
1126
|
+
return (field.fields ?? []).every((child) => isRequiredIntakeFieldComplete(child, record[child.id]));
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
if (field.type === 'number') {
|
|
1130
|
+
if (typeof value === 'number')
|
|
1131
|
+
return Number.isFinite(value);
|
|
1132
|
+
if (typeof value === 'string')
|
|
1133
|
+
return value.trim().length > 0 && Number.isFinite(Number(value));
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
1137
|
+
}
|
|
1138
|
+
function readFiniteNumber(value, fallback) {
|
|
1139
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1140
|
+
return value;
|
|
1141
|
+
if (typeof value === 'string' && value.trim()) {
|
|
1142
|
+
const parsed = Number(value);
|
|
1143
|
+
if (Number.isFinite(parsed))
|
|
1144
|
+
return parsed;
|
|
1145
|
+
}
|
|
1146
|
+
return fallback;
|
|
1147
|
+
}
|
|
1148
|
+
function IntakeField({ field, value, onChange, idPrefix, }) {
|
|
1149
|
+
const id = `${idPrefix}-${field.id}`;
|
|
1150
|
+
const label = field.labelEs ?? field.label ?? field.labelEn ?? field.id;
|
|
1151
|
+
const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
|
1152
|
+
if (field.type === 'repeatable-group') {
|
|
1153
|
+
const items = Array.isArray(value) && value.length > 0 ? value : [{}];
|
|
1154
|
+
return (_jsxs("div", { className: "bw-field bw-field--wide bw-repeatable", children: [_jsx("label", { children: label }), items.map((item, index) => {
|
|
1155
|
+
const record = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
|
|
1156
|
+
return (_jsxs("div", { className: "bw-repeatable-item", children: [_jsxs("div", { className: "bw-repeatable-head", children: [_jsxs("span", { children: ["Caja ", index + 1] }), items.length > 1 ? (_jsx("button", { type: "button", className: "bw-link-btn", onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)), children: "Quitar" })) : null] }), _jsx("div", { className: "bw-form-fields", children: (field.fields ?? []).map((child) => (_jsx(IntakeField, { field: child, value: record[child.id], idPrefix: `${id}-${index}`, onChange: (nextValue) => {
|
|
1157
|
+
const nextItems = [...items];
|
|
1158
|
+
nextItems[index] = { ...record, [child.id]: nextValue };
|
|
1159
|
+
onChange(nextItems);
|
|
1160
|
+
} }, child.id))) })] }, index));
|
|
1161
|
+
}), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: "Agregar otra caja" })] }));
|
|
1162
|
+
}
|
|
1163
|
+
if (field.type === 'checkbox') {
|
|
1164
|
+
return (_jsxs("div", { className: "bw-field bw-field--wide bw-checkbox-field", children: [_jsxs("label", { htmlFor: id, children: [_jsx("input", { id: id, type: "checkbox", checked: value === true, onChange: (event) => onChange(event.target.checked) }), _jsxs("span", { children: [label, field.required ? ' *' : ''] })] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
|
|
1165
|
+
}
|
|
1166
|
+
if (field.type === 'select') {
|
|
1167
|
+
return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsxs("select", { id: id, value: stringValue, required: field.required, onChange: (event) => onChange(event.target.value), children: [_jsx("option", { value: "", children: "Selecciona" }), (field.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.labelEs ?? option.labelEn ?? option.label }, option.value)))] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
|
|
1168
|
+
}
|
|
1169
|
+
if (field.type === 'textarea') {
|
|
1170
|
+
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] }));
|
|
1171
|
+
}
|
|
1172
|
+
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) => {
|
|
1173
|
+
onChange(field.type === 'number'
|
|
1174
|
+
? event.target.value === ''
|
|
1175
|
+
? ''
|
|
1176
|
+
: Number(event.target.value)
|
|
1177
|
+
: event.target.value);
|
|
1178
|
+
} }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
|
|
1179
|
+
}
|
|
1180
|
+
function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
|
|
1181
|
+
const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
|
|
1182
|
+
return (_jsx(Elements, { stripe: stripePromise, options: {
|
|
1183
|
+
clientSecret: payment.clientSecret,
|
|
1184
|
+
}, children: _jsx(StripePaymentForm, { amountCents: payment.amountCents, currency: payment.currency, appointmentId: appointmentId, showInlineButton: showInlineButton, actionTarget: actionTarget, onSuccess: onSuccess, onError: onError }) }));
|
|
1185
|
+
}
|
|
1186
|
+
function StripePaymentForm({ amountCents, currency, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
|
|
1187
|
+
const stripe = useStripe();
|
|
1188
|
+
const elements = useElements();
|
|
1189
|
+
const [isPaying, setIsPaying] = useState(false);
|
|
1190
|
+
async function handlePay() {
|
|
1191
|
+
if (!stripe || !elements)
|
|
1192
|
+
return;
|
|
1193
|
+
setIsPaying(true);
|
|
1194
|
+
const result = await stripe.confirmPayment({
|
|
1195
|
+
elements,
|
|
1196
|
+
confirmParams: {
|
|
1197
|
+
return_url: `${window.location.origin}${window.location.pathname}?appointment_id=${appointmentId ?? ''}`,
|
|
1198
|
+
},
|
|
1199
|
+
redirect: 'if_required',
|
|
1200
|
+
});
|
|
1201
|
+
setIsPaying(false);
|
|
1202
|
+
if (result.error) {
|
|
1203
|
+
onError(result.error.message ?? 'Payment failed. Please try again.');
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
onSuccess();
|
|
1207
|
+
}
|
|
1208
|
+
const renderPayButton = () => (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !stripe || !elements || isPaying, onClick: () => void handlePay(), children: [_jsx("span", { children: isPaying ? 'Procesando...' : 'Pagar y confirmar' }), !isPaying ? _jsx(ArrowRightIcon, {}) : null] }));
|
|
1209
|
+
return (_jsxs("div", { className: "bw-pay", children: [_jsx("div", { className: "bw-pay-summary", children: _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: "Total due today" }), _jsx("span", { className: "bw-pay-summary-val", children: currencyFormatter(currency).format(amountCents / 100) })] }) }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: "Pago seguro" }), _jsx("div", { className: "bw-pay-card-slot", children: _jsx(PaymentElement, {}) })] }), showInlineButton ? renderPayButton() : null, actionTarget ? createPortal(renderPayButton(), actionTarget) : null] }));
|
|
1210
|
+
}
|
|
1211
|
+
function ServiceWarningCallout({ warning }) {
|
|
1212
|
+
return (_jsxs("div", { className: "bw-service-warning", role: "alert", children: [_jsx("div", { className: "bw-service-warning-icon", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }), _jsxs("div", { className: "bw-service-warning-copy", children: [_jsx("div", { className: "bw-service-warning-title", children: warning.title }), _jsx("p", { children: warning.body }), warning.linkHref && warning.linkLabel ? (_jsx("a", { href: warning.linkHref, className: "bw-service-warning-link", children: warning.linkLabel })) : null] })] }));
|
|
1213
|
+
}
|
|
1214
|
+
function formatPrice(service) {
|
|
1215
|
+
if (!service)
|
|
1216
|
+
return '';
|
|
1217
|
+
return formatServicePrice(service);
|
|
1218
|
+
}
|
|
1219
|
+
function formatServicePrice(service, quote) {
|
|
1220
|
+
if (service.pricingMode === 'calculated') {
|
|
1221
|
+
if (quote) {
|
|
1222
|
+
return currencyFormatter(quote.currency).format(quote.totalCents / 100);
|
|
1223
|
+
}
|
|
1224
|
+
return 'Price calculated after details';
|
|
1225
|
+
}
|
|
1226
|
+
return currencyFormatter(service.currency).format(service.priceCents / 100);
|
|
1227
|
+
}
|
|
1228
|
+
function getServiceWarning(service, mode) {
|
|
1229
|
+
if (!service || mode !== 'async')
|
|
1230
|
+
return null;
|
|
1231
|
+
const warning = readRecord(service.publicCopy?.remoteWarning);
|
|
1232
|
+
if (!warning)
|
|
1233
|
+
return null;
|
|
1234
|
+
const title = readString(warning.titleEs) ?? readString(warning.title);
|
|
1235
|
+
const body = readString(warning.bodyEs) ?? readString(warning.body);
|
|
1236
|
+
if (!title || !body)
|
|
1237
|
+
return null;
|
|
1238
|
+
return {
|
|
1239
|
+
title,
|
|
1240
|
+
body,
|
|
1241
|
+
linkHref: readString(warning.linkHref) ?? undefined,
|
|
1242
|
+
linkLabel: readString(warning.linkLabelEs) ?? readString(warning.linkLabel) ?? undefined,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function readRecord(value) {
|
|
1246
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1247
|
+
return null;
|
|
1248
|
+
return value;
|
|
1249
|
+
}
|
|
1250
|
+
function readString(value) {
|
|
1251
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
453
1252
|
}
|
|
454
1253
|
function BookingWidgetSkeleton() {
|
|
455
|
-
|
|
1254
|
+
// Mirrors the live widget's DOM at first load so the chrome
|
|
1255
|
+
// doesn't reflow when real content arrives. Three-column body on
|
|
1256
|
+
// desktop (services / calendar / slots), single-column on mobile.
|
|
1257
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "bw-header", children: _jsx("div", { className: "bw-skel bw-skel--title" }) }), _jsx("div", { className: "bw-content", children: _jsxs("div", { className: "bw-body bw-skel-body", children: [_jsx("div", { className: "bw-col bw-col--left", children: _jsx("div", { className: "bw-step-1", children: _jsxs("div", { className: "bw-service-picker", children: [_jsx("div", { className: "bw-skel bw-skel--label" }), [
|
|
1258
|
+
{ rows: 1 },
|
|
1259
|
+
{ rows: 2 },
|
|
1260
|
+
{ rows: 1 },
|
|
1261
|
+
].map((group, groupIndex) => (_jsxs("div", { className: "bw-skel-svc-group", children: [_jsx("div", { className: "bw-skel bw-skel--svc-category" }), Array.from({ length: group.rows }).map((_, rowIndex) => (_jsxs("div", { className: "bw-skel-svc-row", children: [_jsx("div", { className: "bw-skel bw-skel--svc-image" }), _jsxs("div", { className: "bw-skel-svc-info", children: [_jsx("div", { className: "bw-skel bw-skel--svc-name" }), _jsx("div", { className: "bw-skel bw-skel--svc-desc" }), _jsx("div", { className: "bw-skel bw-skel--svc-meta" })] })] }, rowIndex)))] }, groupIndex)))] }) }) }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card bw-skel-cal-card", children: [_jsxs("div", { className: "bw-skel-cal-header", children: [_jsx("div", { className: "bw-skel bw-skel--cal-month" }), _jsxs("div", { className: "bw-skel-cal-navs", children: [_jsx("div", { className: "bw-skel bw-skel--cal-nav" }), _jsx("div", { className: "bw-skel bw-skel--cal-nav" })] })] }), _jsx("div", { className: "bw-skel-cal-dows", children: Array.from({ length: 7 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-dow" }, index))) }), _jsx("div", { className: "bw-skel-cal-grid", children: Array.from({ length: 42 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-day" }, index))) }), _jsx("div", { className: "bw-skel bw-skel--cal-tz" })] }) }), _jsx("div", { className: "bw-col bw-col--right", children: _jsxs("div", { className: "bw-pane--slots", children: [_jsx("div", { className: "bw-skel bw-skel--slots-heading" }), _jsx("div", { className: "bw-time-slots bw-time-slots--skeleton", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) })] }) })] }) })] }));
|
|
456
1262
|
}
|
|
457
1263
|
function AvailabilitySkeleton() {
|
|
458
|
-
|
|
1264
|
+
// Reuses .bw-time-slots so the skeleton inherits the same layout
|
|
1265
|
+
// overrides as the real slots — vertical 1-col on desktop (via the
|
|
1266
|
+
// .bw-slots-desktop scope), 4-col grid on mobile (default). Pill
|
|
1267
|
+
// dimensions match the rendered .bw-slot.
|
|
1268
|
+
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))) }));
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Compact "Wed, May 6 · 3:00 PM – 5:00 PM" label used in reschedule
|
|
1272
|
+
* summary's "Former time" row. Uses local timezone of the browser;
|
|
1273
|
+
* the backend stores absolute ms timestamps so this renders correctly
|
|
1274
|
+
* for the host's calendar.
|
|
1275
|
+
*/
|
|
1276
|
+
function formatFormerSlot(startMs, endMs) {
|
|
1277
|
+
const start = new Date(startMs);
|
|
1278
|
+
const end = new Date(endMs);
|
|
1279
|
+
const dateLabel = start.toLocaleDateString([], {
|
|
1280
|
+
weekday: 'short',
|
|
1281
|
+
month: 'short',
|
|
1282
|
+
day: 'numeric',
|
|
1283
|
+
});
|
|
1284
|
+
const timeOpts = {
|
|
1285
|
+
hour: 'numeric',
|
|
1286
|
+
minute: '2-digit',
|
|
1287
|
+
};
|
|
1288
|
+
return `${dateLabel} · ${start.toLocaleTimeString([], timeOpts)} – ${end.toLocaleTimeString([], timeOpts)}`;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Possessive form of a name. "Eric Lan" → "Eric Lan's", "James" →
|
|
1292
|
+
* "James'". Splits on the full string's last char so single-name
|
|
1293
|
+
* customers (and last-name-only handles, etc.) still resolve cleanly.
|
|
1294
|
+
*/
|
|
1295
|
+
function formatPossessive(name) {
|
|
1296
|
+
const trimmed = name.trim();
|
|
1297
|
+
if (!trimmed)
|
|
1298
|
+
return trimmed;
|
|
1299
|
+
const last = trimmed[trimmed.length - 1];
|
|
1300
|
+
if (last === 's' || last === 'S')
|
|
1301
|
+
return `${trimmed}'`;
|
|
1302
|
+
return `${trimmed}'s`;
|
|
459
1303
|
}
|
|
460
1304
|
function mobileStepTitle(step) {
|
|
461
1305
|
switch (step) {
|
|
462
1306
|
case 1:
|
|
463
|
-
return '
|
|
1307
|
+
return 'Choose a service';
|
|
464
1308
|
case 2:
|
|
465
|
-
return '
|
|
1309
|
+
return 'Pick a provider';
|
|
466
1310
|
case 3:
|
|
467
1311
|
return 'Pick a date & time';
|
|
468
1312
|
case 4:
|
|
@@ -473,7 +1317,8 @@ function mobileStepTitle(step) {
|
|
|
473
1317
|
}
|
|
474
1318
|
function formatDuration(minutes) {
|
|
475
1319
|
if (minutes >= 60 && minutes % 60 === 0) {
|
|
476
|
-
|
|
1320
|
+
const hours = minutes / 60;
|
|
1321
|
+
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
477
1322
|
}
|
|
478
1323
|
return `${minutes} min`;
|
|
479
1324
|
}
|
|
@@ -633,10 +1478,22 @@ function ArrowRightIcon() {
|
|
|
633
1478
|
function CheckIcon() {
|
|
634
1479
|
return iconPath('M20 6 9 17l-5-5');
|
|
635
1480
|
}
|
|
1481
|
+
function XIcon() {
|
|
1482
|
+
return iconPath('M18 6 6 18M6 6l12 12');
|
|
1483
|
+
}
|
|
1484
|
+
function WarningIcon() {
|
|
1485
|
+
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" })] }));
|
|
1486
|
+
}
|
|
636
1487
|
function UserIcon() {
|
|
637
1488
|
return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
|
|
638
1489
|
}
|
|
639
1490
|
function GlobeIcon() {
|
|
640
1491
|
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');
|
|
641
1492
|
}
|
|
1493
|
+
function CalendarIcon() {
|
|
1494
|
+
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" })] }));
|
|
1495
|
+
}
|
|
1496
|
+
function ClockIcon() {
|
|
1497
|
+
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" })] }));
|
|
1498
|
+
}
|
|
642
1499
|
//# sourceMappingURL=booking-widget.js.map
|