@asksable/site-connector 0.1.6 → 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 +953 -130
- package/dist/booking-widget.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +35 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/provider.d.ts +1 -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,31 +1,102 @@
|
|
|
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());
|
|
@@ -33,6 +104,29 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
33
104
|
const staffMenuRef = useRef(null);
|
|
34
105
|
const availabilityCacheRef = useRef(new Map());
|
|
35
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]);
|
|
36
130
|
useEffect(() => {
|
|
37
131
|
holdIdRef.current = holdId;
|
|
38
132
|
}, [holdId]);
|
|
@@ -45,8 +139,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
45
139
|
if (cancelled) {
|
|
46
140
|
return;
|
|
47
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
|
+
}
|
|
48
146
|
setSetup(result);
|
|
49
|
-
|
|
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.
|
|
50
150
|
setError(null);
|
|
51
151
|
})
|
|
52
152
|
.catch((nextError) => {
|
|
@@ -77,6 +177,90 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
77
177
|
};
|
|
78
178
|
}, [staffOpen]);
|
|
79
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);
|
|
80
264
|
const availableStaff = useMemo(() => {
|
|
81
265
|
if (!setup || !selectedService) {
|
|
82
266
|
return [];
|
|
@@ -84,13 +268,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
84
268
|
return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
|
|
85
269
|
}, [selectedService, setup]);
|
|
86
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;
|
|
87
276
|
if (selectedStaffId && !availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
|
|
88
277
|
setSelectedStaffId(null);
|
|
89
278
|
}
|
|
90
279
|
}, [availableStaff, selectedStaffId]);
|
|
91
280
|
const selectedStaff = useMemo(() => availableStaff.find((staffMember) => staffMember._id === selectedStaffId) ?? null, [availableStaff, selectedStaffId]);
|
|
92
281
|
useEffect(() => {
|
|
93
|
-
if (!selectedServiceId) {
|
|
282
|
+
if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
|
|
94
283
|
setAvailabilityByDate(new Map());
|
|
95
284
|
setSelectedDate(null);
|
|
96
285
|
return;
|
|
@@ -154,9 +343,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
154
343
|
return () => {
|
|
155
344
|
cancelled = true;
|
|
156
345
|
};
|
|
157
|
-
}, [calendarMonth, client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
|
|
346
|
+
}, [calendarMonth, client, selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
|
|
158
347
|
useEffect(() => {
|
|
159
|
-
if (!selectedServiceId) {
|
|
348
|
+
if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
|
|
160
349
|
return;
|
|
161
350
|
}
|
|
162
351
|
const nextMonth = addMonths(calendarMonth, 1);
|
|
@@ -168,14 +357,65 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
168
357
|
calendarMonth: nextMonth,
|
|
169
358
|
cacheRef: availabilityCacheRef,
|
|
170
359
|
});
|
|
171
|
-
}, [calendarMonth, client, selectedServiceId, selectedStaffId, siteSlug]);
|
|
360
|
+
}, [calendarMonth, client, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
|
|
172
361
|
useEffect(() => {
|
|
173
362
|
setSelectedSlot(null);
|
|
174
363
|
setPendingSlotKey(null);
|
|
175
364
|
setHeldStaffId(null);
|
|
176
365
|
setSuccess(null);
|
|
177
366
|
setError(null);
|
|
178
|
-
|
|
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]);
|
|
179
419
|
useEffect(() => {
|
|
180
420
|
return () => {
|
|
181
421
|
if (!holdId) {
|
|
@@ -264,11 +504,103 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
264
504
|
const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
|
|
265
505
|
const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
|
|
266
506
|
const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
|
|
267
|
-
|
|
268
|
-
|
|
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;
|
|
269
513
|
const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
604
|
async function handleSlotSelect(slot) {
|
|
273
605
|
if (!selectedServiceId || !selectedDate) {
|
|
274
606
|
return;
|
|
@@ -313,6 +645,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
313
645
|
setHoldExpiresAt(result.expiresAt);
|
|
314
646
|
setHeldStaffId(reservationStaffId);
|
|
315
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');
|
|
316
653
|
}
|
|
317
654
|
catch (nextError) {
|
|
318
655
|
setSelectedSlot(previousSlot);
|
|
@@ -323,28 +660,96 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
323
660
|
setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
|
|
324
661
|
}
|
|
325
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
|
+
}
|
|
326
690
|
async function handleConfirmBooking() {
|
|
327
|
-
if (!selectedServiceId ||
|
|
691
|
+
if (!selectedServiceId ||
|
|
692
|
+
!selectedService ||
|
|
693
|
+
(selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId))) {
|
|
328
694
|
return;
|
|
329
695
|
}
|
|
330
696
|
setIsSubmitting(true);
|
|
331
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
|
+
}
|
|
332
727
|
try {
|
|
333
|
-
await client.createPublicBooking({
|
|
728
|
+
const created = await client.createPublicBooking({
|
|
334
729
|
siteSlug,
|
|
335
730
|
serviceId: selectedServiceId,
|
|
336
|
-
staffMemberId:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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',
|
|
340
737
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
341
738
|
customerName: customerName.trim(),
|
|
342
739
|
customerEmail: customerEmail.trim(),
|
|
343
740
|
customerPhone: customerPhone.trim() || undefined,
|
|
344
741
|
customerNotes: customerNotes.trim() || undefined,
|
|
345
|
-
|
|
346
|
-
|
|
742
|
+
intakeResponses,
|
|
743
|
+
quotedTotalCents: quote?.totalCents,
|
|
744
|
+
bookingHoldId: selectedServiceRequiresSlot ? holdId : undefined,
|
|
745
|
+
bookingSessionToken: selectedServiceRequiresSlot ? sessionToken : undefined,
|
|
347
746
|
});
|
|
747
|
+
if (created.payment) {
|
|
748
|
+
setPayment(created.payment);
|
|
749
|
+
setPaymentAppointmentId(created.appointmentId);
|
|
750
|
+
setError(null);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
348
753
|
setSuccess('Booking confirmed.');
|
|
349
754
|
setSelectedSlot(null);
|
|
350
755
|
setPendingSlotKey(null);
|
|
@@ -355,6 +760,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
355
760
|
setCustomerEmail('');
|
|
356
761
|
setCustomerPhone('');
|
|
357
762
|
setCustomerNotes('');
|
|
763
|
+
setIntakeResponses({});
|
|
764
|
+
setQuote(null);
|
|
765
|
+
setPayment(null);
|
|
766
|
+
setPaymentAppointmentId(null);
|
|
358
767
|
setMobileStep(4);
|
|
359
768
|
}
|
|
360
769
|
catch (nextError) {
|
|
@@ -370,133 +779,534 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
|
|
|
370
779
|
if (!setup || setup.services.length === 0) {
|
|
371
780
|
return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
|
|
372
781
|
}
|
|
373
|
-
if (
|
|
374
|
-
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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) {
|
|
388
860
|
void prefetchAvailability({
|
|
389
861
|
client,
|
|
390
862
|
siteSlug,
|
|
391
|
-
selectedServiceId
|
|
863
|
+
selectedServiceId,
|
|
392
864
|
selectedStaffId,
|
|
393
|
-
calendarMonth,
|
|
865
|
+
calendarMonth: addMonths(calendarMonth, -1),
|
|
394
866
|
cacheRef: availabilityCacheRef,
|
|
395
867
|
});
|
|
396
|
-
}
|
|
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) {
|
|
397
871
|
void prefetchAvailability({
|
|
398
872
|
client,
|
|
399
873
|
siteSlug,
|
|
400
|
-
selectedServiceId
|
|
874
|
+
selectedServiceId,
|
|
401
875
|
selectedStaffId,
|
|
402
|
-
calendarMonth,
|
|
876
|
+
calendarMonth: addMonths(calendarMonth, 1),
|
|
403
877
|
cacheRef: availabilityCacheRef,
|
|
404
878
|
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}, children:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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) ||
|
|
485
963
|
(mobileStep === 2 && !canAdvanceStep2) ||
|
|
486
|
-
(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;
|
|
487
1252
|
}
|
|
488
1253
|
function BookingWidgetSkeleton() {
|
|
489
|
-
|
|
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))) })] }) })] }) })] }));
|
|
490
1262
|
}
|
|
491
1263
|
function AvailabilitySkeleton() {
|
|
492
|
-
|
|
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`;
|
|
493
1303
|
}
|
|
494
1304
|
function mobileStepTitle(step) {
|
|
495
1305
|
switch (step) {
|
|
496
1306
|
case 1:
|
|
497
|
-
return '
|
|
1307
|
+
return 'Choose a service';
|
|
498
1308
|
case 2:
|
|
499
|
-
return '
|
|
1309
|
+
return 'Pick a provider';
|
|
500
1310
|
case 3:
|
|
501
1311
|
return 'Pick a date & time';
|
|
502
1312
|
case 4:
|
|
@@ -507,7 +1317,8 @@ function mobileStepTitle(step) {
|
|
|
507
1317
|
}
|
|
508
1318
|
function formatDuration(minutes) {
|
|
509
1319
|
if (minutes >= 60 && minutes % 60 === 0) {
|
|
510
|
-
|
|
1320
|
+
const hours = minutes / 60;
|
|
1321
|
+
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
511
1322
|
}
|
|
512
1323
|
return `${minutes} min`;
|
|
513
1324
|
}
|
|
@@ -667,10 +1478,22 @@ function ArrowRightIcon() {
|
|
|
667
1478
|
function CheckIcon() {
|
|
668
1479
|
return iconPath('M20 6 9 17l-5-5');
|
|
669
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
|
+
}
|
|
670
1487
|
function UserIcon() {
|
|
671
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');
|
|
672
1489
|
}
|
|
673
1490
|
function GlobeIcon() {
|
|
674
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');
|
|
675
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
|
+
}
|
|
676
1499
|
//# sourceMappingURL=booking-widget.js.map
|