@asksable/site-connector 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/booking-widget.d.ts +21 -1
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +427 -72
- package/dist/booking-widget.js.map +1 -1
- package/dist/styles.css +107 -0
- package/dist/translations.d.ts +24 -0
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +24 -0
- package/dist/translations.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/booking-widget.js
CHANGED
|
@@ -11,6 +11,29 @@ import './bones/registry.js';
|
|
|
11
11
|
import { BookingWidgetPlaceholder } from './booking-widget-placeholder.js';
|
|
12
12
|
import { useSableSiteAnalytics, useSableSiteClient, useSableSiteConfig, useTranslation, } from './provider.js';
|
|
13
13
|
import { DEFAULT_LOCALE, localeToIntl, pickLocaleField, } from './translations.js';
|
|
14
|
+
const FLEXIBLE_WINDOW_OPTIONS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'all-day',
|
|
17
|
+
startTime: '09:00',
|
|
18
|
+
endTime: '17:00',
|
|
19
|
+
labelKey: 'flexWindowAllDay',
|
|
20
|
+
descriptionKey: 'flexWindowAllDayDesc',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'morning',
|
|
24
|
+
startTime: '09:00',
|
|
25
|
+
endTime: '12:00',
|
|
26
|
+
labelKey: 'flexWindowMorning',
|
|
27
|
+
descriptionKey: 'flexWindowMorningDesc',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'afternoon',
|
|
31
|
+
startTime: '12:00',
|
|
32
|
+
endTime: '17:00',
|
|
33
|
+
labelKey: 'flexWindowAfternoon',
|
|
34
|
+
descriptionKey: 'flexWindowAfternoonDesc',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
14
37
|
const stripePromises = new Map();
|
|
15
38
|
function getStripePromise(publishableKey, connectAccountId) {
|
|
16
39
|
const key = `${publishableKey}:${connectAccountId}`;
|
|
@@ -35,7 +58,7 @@ function findCalculatedServiceMissingIntake(setup) {
|
|
|
35
58
|
}
|
|
36
59
|
const MOBILE_PROGRESS_STEPS_SCHEDULED = [1, 2, 3, 4];
|
|
37
60
|
const MOBILE_PROGRESS_STEPS_ASYNC = [1, 3, 4];
|
|
38
|
-
export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, initialSelection, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
|
|
61
|
+
export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, initialSelection, allowFlexibleScheduling = false, defaultSchedulingPreference = 'exact', successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
|
|
39
62
|
const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
|
|
40
63
|
const isReschedule = mode === 'reschedule';
|
|
41
64
|
// Reschedule summary holds longer values (full former-time
|
|
@@ -52,7 +75,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
52
75
|
}
|
|
53
76
|
: undefined;
|
|
54
77
|
const client = useSableSiteClient();
|
|
55
|
-
const { siteSlug } = useSableSiteConfig();
|
|
78
|
+
const { siteSlug, timezone: configuredTimezone } = useSableSiteConfig();
|
|
56
79
|
const { t, locale } = useTranslation();
|
|
57
80
|
const analytics = useSableSiteAnalytics();
|
|
58
81
|
const intlLocale = localeToIntl(locale);
|
|
@@ -71,6 +94,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
71
94
|
const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
|
|
72
95
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
73
96
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
97
|
+
const [schedulingPreference, setSchedulingPreference] = useState(() => defaultSchedulingPreference === 'flexible' ? 'flexible' : 'exact');
|
|
98
|
+
const [selectedFlexibleWindowId, setSelectedFlexibleWindowId] = useState(() => FLEXIBLE_WINDOW_OPTIONS[0]?.id ?? 'all-day');
|
|
74
99
|
const [pendingSlotKey, setPendingSlotKey] = useState(null);
|
|
75
100
|
// Service picker is progressive: full list shows initially, collapses
|
|
76
101
|
// to a single "selected service" card once a service is picked, then
|
|
@@ -119,6 +144,15 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
119
144
|
setViewState('details');
|
|
120
145
|
}
|
|
121
146
|
}, [__devForceState]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!allowFlexibleScheduling) {
|
|
149
|
+
setSchedulingPreference('exact');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (defaultSchedulingPreference === 'flexible') {
|
|
153
|
+
setSchedulingPreference('flexible');
|
|
154
|
+
}
|
|
155
|
+
}, [allowFlexibleScheduling, defaultSchedulingPreference]);
|
|
122
156
|
const [monthOpen, setMonthOpen] = useState(false);
|
|
123
157
|
const [holdNow, setHoldNow] = useState(() => Date.now());
|
|
124
158
|
const [sessionToken] = useState(() => sessionTokenFromBrowser());
|
|
@@ -131,6 +165,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
131
165
|
const initialStaffMemberId = initialSelection?.staffMemberId;
|
|
132
166
|
const initialCustomerNotes = initialSelection?.customerNotes;
|
|
133
167
|
const initialIntakeResponses = initialSelection?.intakeResponses;
|
|
168
|
+
const initialQuoteOverride = useMemo(() => getInitialQuoteOverride(initialSelection), [initialSelection]);
|
|
134
169
|
const availabilityCacheRef = useRef(new Map());
|
|
135
170
|
const holdIdRef = useRef(null);
|
|
136
171
|
// Calendar card lives in the middle column and is the natural
|
|
@@ -231,7 +266,23 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
231
266
|
]);
|
|
232
267
|
const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ??
|
|
233
268
|
null, [selectedServiceId, setup]);
|
|
269
|
+
const selectedQuoteOverride = selectedService && initialQuoteOverride
|
|
270
|
+
? getApplicableInitialQuoteOverride(initialSelection, selectedService, initialQuoteOverride)
|
|
271
|
+
: null;
|
|
272
|
+
const displayQuote = selectedService && selectedQuoteOverride
|
|
273
|
+
? quoteFromOverride(selectedService, selectedQuoteOverride)
|
|
274
|
+
: quote;
|
|
275
|
+
const isDisplayQuoteLoading = isQuoteLoading && !displayQuote;
|
|
234
276
|
const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
|
|
277
|
+
const flexibleSchedulingEnabled = allowFlexibleScheduling && selectedServiceRequiresSlot && !isReschedule;
|
|
278
|
+
const effectiveSchedulingPreference = flexibleSchedulingEnabled ? schedulingPreference : 'exact';
|
|
279
|
+
const selectedFlexibleWindow = FLEXIBLE_WINDOW_OPTIONS.find((option) => option.id === selectedFlexibleWindowId) ?? FLEXIBLE_WINDOW_OPTIONS[0];
|
|
280
|
+
const bookingTimezone = resolveBookingTimezone({
|
|
281
|
+
workspaceTimezone: setup?.workspaceTimezone,
|
|
282
|
+
configuredTimezone,
|
|
283
|
+
fallbackStaffTimezone: setup?.staff?.[0]?.timezone,
|
|
284
|
+
});
|
|
285
|
+
const todayDateKey = dateKeyInTimeZone(Date.now(), bookingTimezone);
|
|
235
286
|
const selectedServicePath = selectedServiceRequiresSlot
|
|
236
287
|
? 'scheduled'
|
|
237
288
|
: 'async';
|
|
@@ -341,6 +392,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
341
392
|
setSelectedDate(null);
|
|
342
393
|
return;
|
|
343
394
|
}
|
|
395
|
+
if (effectiveSchedulingPreference === 'flexible') {
|
|
396
|
+
setAvailabilityByDate(new Map());
|
|
397
|
+
setIsAvailabilityLoading(false);
|
|
398
|
+
if (selectedDate &&
|
|
399
|
+
!isFlexibleDateSelectable({
|
|
400
|
+
dateKey: selectedDate,
|
|
401
|
+
service: selectedService,
|
|
402
|
+
businessHours: setup?.businessHours ?? [],
|
|
403
|
+
timezone: bookingTimezone,
|
|
404
|
+
window: selectedFlexibleWindow,
|
|
405
|
+
})) {
|
|
406
|
+
setSelectedDate(null);
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
344
410
|
let cancelled = false;
|
|
345
411
|
const monthStart = formatDateKey(calendarMonth);
|
|
346
412
|
const monthEnd = formatDateKey(endOfMonth(calendarMonth));
|
|
@@ -411,14 +477,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
411
477
|
}, [
|
|
412
478
|
calendarMonth,
|
|
413
479
|
client,
|
|
480
|
+
bookingTimezone,
|
|
481
|
+
effectiveSchedulingPreference,
|
|
414
482
|
selectedDate,
|
|
415
483
|
selectedService?.publicBookingMode,
|
|
416
484
|
selectedServiceId,
|
|
417
485
|
selectedStaffId,
|
|
486
|
+
selectedFlexibleWindow,
|
|
487
|
+
selectedService,
|
|
488
|
+
setup?.businessHours,
|
|
418
489
|
siteSlug,
|
|
419
490
|
]);
|
|
420
491
|
useEffect(() => {
|
|
421
|
-
if (!selectedServiceId ||
|
|
492
|
+
if (!selectedServiceId ||
|
|
493
|
+
selectedService?.publicBookingMode === 'async' ||
|
|
494
|
+
effectiveSchedulingPreference === 'flexible') {
|
|
422
495
|
return;
|
|
423
496
|
}
|
|
424
497
|
const nextMonth = addMonths(calendarMonth, 1);
|
|
@@ -432,6 +505,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
432
505
|
}, [
|
|
433
506
|
calendarMonth,
|
|
434
507
|
client,
|
|
508
|
+
effectiveSchedulingPreference,
|
|
435
509
|
selectedService?.publicBookingMode,
|
|
436
510
|
selectedServiceId,
|
|
437
511
|
selectedStaffId,
|
|
@@ -645,6 +719,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
645
719
|
// date (regardless of who's currently filtered). Drives the
|
|
646
720
|
// "Unavailable" subtitle + disabled state in the provider dropdown.
|
|
647
721
|
const staffIdsAvailableOnSelectedDate = useMemo(() => {
|
|
722
|
+
if (effectiveSchedulingPreference === 'flexible')
|
|
723
|
+
return null;
|
|
648
724
|
if (!selectedDate)
|
|
649
725
|
return null;
|
|
650
726
|
const slots = availabilityByDate.get(selectedDate);
|
|
@@ -657,7 +733,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
657
733
|
}
|
|
658
734
|
}
|
|
659
735
|
return set;
|
|
660
|
-
}, [availabilityByDate, selectedDate]);
|
|
736
|
+
}, [availabilityByDate, effectiveSchedulingPreference, selectedDate]);
|
|
661
737
|
const selectedDateSlots = selectedDate
|
|
662
738
|
? (filteredAvailabilityByDate.get(selectedDate) ?? [])
|
|
663
739
|
: [];
|
|
@@ -676,7 +752,16 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
676
752
|
// valid email, and any required intake fields filled. Step 4 is
|
|
677
753
|
// the review screen (submit, not advance).
|
|
678
754
|
const canAdvanceStep1 = Boolean(selectedService);
|
|
679
|
-
const
|
|
755
|
+
const hasScheduleSelection = !selectedServiceRequiresSlot ||
|
|
756
|
+
(effectiveSchedulingPreference === 'flexible'
|
|
757
|
+
? Boolean(selectedDate && selectedFlexibleWindow)
|
|
758
|
+
: Boolean(selectedDate && selectedSlot && holdId));
|
|
759
|
+
const selectedTimeLabel = selectedDate && effectiveSchedulingPreference === 'flexible'
|
|
760
|
+
? formatFlexibleWindowTimeRange(selectedFlexibleWindow, intlLocale)
|
|
761
|
+
: selectedSlot
|
|
762
|
+
? `${formatTimeLabel(selectedSlot.time, intlLocale)} – ${formatTimeLabel(selectedSlot.endTime, intlLocale)}`
|
|
763
|
+
: null;
|
|
764
|
+
const canAdvanceStep2 = hasScheduleSelection;
|
|
680
765
|
const canAdvanceStep3 = Boolean(selectedService &&
|
|
681
766
|
customerName.trim() &&
|
|
682
767
|
isCustomerEmailValid &&
|
|
@@ -706,8 +791,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
706
791
|
staffIdsAvailableOnSelectedDate.has(staffMember._id);
|
|
707
792
|
return (_jsxs("button", { type: "button", role: "option", "aria-selected": isActive, "aria-disabled": !isAvailable, disabled: !isAvailable, className: `bw-staff-card${isActive ? ' is-active' : ''}${!isAvailable ? ' is-disabled' : ''}`, onClick: () => setSelectedStaffId(staffMember._id), children: [_jsx("span", { className: "bw-staff-card-avatar", children: staffMember.image?.url ? (_jsx("img", { src: staffMember.image.url, alt: staffMember.image.alt ?? staffMember.name, className: "bw-staff-avatar-img" })) : (_jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) })) }), _jsxs("span", { className: "bw-staff-card-info", children: [_jsx("span", { className: "bw-staff-card-name", children: staffMember.name }), !isAvailable ? (_jsx("span", { className: "bw-staff-card-desc", children: t('providerUnavailable') })) : null] })] }, staffMember._id));
|
|
708
793
|
})] })) : null;
|
|
709
|
-
const
|
|
710
|
-
const
|
|
794
|
+
const schedulingPreferenceControl = flexibleSchedulingEnabled ? (_jsxs("div", { className: "bw-schedule-mode", role: "radiogroup", "aria-label": t('scheduleModeAria'), children: [_jsx("button", { type: "button", role: "radio", "aria-checked": effectiveSchedulingPreference === 'flexible', className: `bw-schedule-mode-btn${effectiveSchedulingPreference === 'flexible' ? ' is-active' : ''}`, onClick: () => handleSchedulingPreferenceChange('flexible'), children: _jsx("span", { children: t('scheduleModeFlexible') }) }), _jsx("button", { type: "button", role: "radio", "aria-checked": effectiveSchedulingPreference === 'exact', className: `bw-schedule-mode-btn${effectiveSchedulingPreference === 'exact' ? ' is-active' : ''}`, onClick: () => handleSchedulingPreferenceChange('exact'), children: _jsx("span", { children: t('scheduleModeExact') }) })] })) : null;
|
|
795
|
+
const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${effectiveSchedulingPreference}-${selectedFlexibleWindowId}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
|
|
796
|
+
const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : effectiveSchedulingPreference === 'flexible' ? (selectedDate ? (_jsx("div", { className: "bw-window-options", children: FLEXIBLE_WINDOW_OPTIONS.map((option, index) => {
|
|
797
|
+
const isActive = selectedFlexibleWindowId === option.id;
|
|
798
|
+
return (_jsxs("button", { type: "button", className: `bw-window-option${isActive ? ' is-active' : ''}`, style: { '--bw-slot-i': index }, onClick: () => handleFlexibleWindowSelect(option.id), children: [_jsxs("span", { className: "bw-window-option-main", children: [_jsx("span", { children: t(option.labelKey) }), _jsx("span", { children: formatFlexibleWindowTimeRange(option, intlLocale) })] }), _jsx("span", { className: "bw-window-option-desc", children: t(option.descriptionKey) })] }, option.id));
|
|
799
|
+
}) })) : (_jsx("p", { className: "bw-no-slots", children: t('flexPickDateFirst') }))) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
|
|
711
800
|
const slotKey = `${slot.time}-${slot.endTime}`;
|
|
712
801
|
const isPending = pendingSlotKey === slotKey;
|
|
713
802
|
const isActive = isPending ||
|
|
@@ -718,8 +807,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
718
807
|
: formatTimeLabel(slot.time, intlLocale) }, slotKey));
|
|
719
808
|
}) })) : (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') }))) : null }, slotsAreaKey));
|
|
720
809
|
const canSubmit = Boolean(selectedService &&
|
|
721
|
-
|
|
722
|
-
(selectedDate && selectedSlot && holdId)) &&
|
|
810
|
+
hasScheduleSelection &&
|
|
723
811
|
isIntakeComplete &&
|
|
724
812
|
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
725
813
|
customerName.trim() &&
|
|
@@ -728,9 +816,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
728
816
|
? getSubmitBlockers({
|
|
729
817
|
selectedService,
|
|
730
818
|
selectedServiceRequiresSlot,
|
|
731
|
-
|
|
732
|
-
selectedSlot,
|
|
733
|
-
holdId,
|
|
819
|
+
hasScheduleSelection,
|
|
734
820
|
isIntakeComplete,
|
|
735
821
|
intakeResponses,
|
|
736
822
|
selectedServicePath,
|
|
@@ -750,7 +836,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
750
836
|
function renderSubmitHelp() {
|
|
751
837
|
if (!selectedService || canSubmit || payment || isSubmitting)
|
|
752
838
|
return null;
|
|
753
|
-
if (submitBlockers.length === 0 && !
|
|
839
|
+
if (submitBlockers.length === 0 && !isDisplayQuoteLoading)
|
|
754
840
|
return null;
|
|
755
841
|
// Only surface the "complete required fields" callout AFTER the
|
|
756
842
|
// user has tried to confirm. The Confirm button stays visually
|
|
@@ -760,7 +846,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
760
846
|
return null;
|
|
761
847
|
const visibleBlockers = submitBlockers.slice(0, 5);
|
|
762
848
|
const hiddenCount = submitBlockers.length - visibleBlockers.length;
|
|
763
|
-
return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children:
|
|
849
|
+
return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isDisplayQuoteLoading ? t('helpCalculating') : t('helpComplete') }), !isDisplayQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? (_jsx("li", { children: t('helpMoreFields', { count: hiddenCount }) })) : null] })) : null] }));
|
|
764
850
|
}
|
|
765
851
|
function renderContactFields(idSuffix) {
|
|
766
852
|
const nameId = `bw-name${idSuffix}`;
|
|
@@ -809,6 +895,35 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
809
895
|
setMobileStep(3);
|
|
810
896
|
}
|
|
811
897
|
}
|
|
898
|
+
function handleSchedulingPreferenceChange(next) {
|
|
899
|
+
if (next === schedulingPreference)
|
|
900
|
+
return;
|
|
901
|
+
if (holdId) {
|
|
902
|
+
void client
|
|
903
|
+
.releasePublicBookingHold({ siteSlug, holdId, sessionToken })
|
|
904
|
+
.catch(() => undefined);
|
|
905
|
+
}
|
|
906
|
+
setSchedulingPreference(next);
|
|
907
|
+
setSelectedSlot(null);
|
|
908
|
+
setHoldId(null);
|
|
909
|
+
setHoldExpiresAt(null);
|
|
910
|
+
setHeldStaffId(null);
|
|
911
|
+
setPendingSlotKey(null);
|
|
912
|
+
setViewState('slots');
|
|
913
|
+
}
|
|
914
|
+
function handleFlexibleWindowSelect(optionId) {
|
|
915
|
+
if (!selectedDate)
|
|
916
|
+
return;
|
|
917
|
+
setSelectedFlexibleWindowId(optionId);
|
|
918
|
+
setSelectedSlot(null);
|
|
919
|
+
setHoldId(null);
|
|
920
|
+
setHoldExpiresAt(null);
|
|
921
|
+
setHeldStaffId(null);
|
|
922
|
+
setPendingSlotKey(null);
|
|
923
|
+
setError(null);
|
|
924
|
+
setViewState('details');
|
|
925
|
+
setMobileStep((current) => (current < 3 ? 3 : current));
|
|
926
|
+
}
|
|
812
927
|
async function handleSlotSelect(slot) {
|
|
813
928
|
if (!selectedServiceId || !selectedDate) {
|
|
814
929
|
return;
|
|
@@ -843,8 +958,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
843
958
|
siteSlug,
|
|
844
959
|
serviceId: selectedServiceId,
|
|
845
960
|
staffMemberId: reservationStaffId,
|
|
846
|
-
startTime:
|
|
847
|
-
endTime:
|
|
961
|
+
startTime: zonedDateTimeToTimestamp(selectedDate, slot.time, bookingTimezone),
|
|
962
|
+
endTime: zonedDateTimeToTimestamp(selectedDate, slot.endTime, bookingTimezone),
|
|
848
963
|
sessionToken,
|
|
849
964
|
});
|
|
850
965
|
setSelectedSlot(slot);
|
|
@@ -899,7 +1014,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
899
1014
|
// The disabled state is purely visual (aria-disabled + class). Real
|
|
900
1015
|
// submittability gates here so we can surface validation help only
|
|
901
1016
|
// after the user actually tries to confirm - never on a fresh form.
|
|
902
|
-
if (!canSubmit ||
|
|
1017
|
+
if (!canSubmit || isDisplayQuoteLoading || isSubmitting) {
|
|
903
1018
|
setSubmitAttempted(true);
|
|
904
1019
|
return;
|
|
905
1020
|
}
|
|
@@ -909,17 +1024,37 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
909
1024
|
if (!selectedServiceId ||
|
|
910
1025
|
!selectedService ||
|
|
911
1026
|
(selectedServiceRequiresSlot &&
|
|
912
|
-
(
|
|
1027
|
+
(effectiveSchedulingPreference === 'flexible'
|
|
1028
|
+
? !selectedDate || !selectedFlexibleWindow
|
|
1029
|
+
: !selectedDate || !selectedSlot || !holdId))) {
|
|
913
1030
|
return;
|
|
914
1031
|
}
|
|
915
1032
|
setIsSubmitting(true);
|
|
916
1033
|
setError(null);
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1034
|
+
const isFlexibleRequest = selectedServiceRequiresSlot &&
|
|
1035
|
+
effectiveSchedulingPreference === 'flexible' &&
|
|
1036
|
+
selectedDate != null &&
|
|
1037
|
+
selectedFlexibleWindow != null;
|
|
1038
|
+
const newStartTime = isFlexibleRequest
|
|
1039
|
+
? zonedDateTimeToTimestamp(selectedDate, selectedFlexibleWindow.startTime, bookingTimezone)
|
|
1040
|
+
: selectedDate && selectedSlot
|
|
1041
|
+
? zonedDateTimeToTimestamp(selectedDate, selectedSlot.time, bookingTimezone)
|
|
1042
|
+
: Date.now();
|
|
1043
|
+
const newEndTime = isFlexibleRequest
|
|
1044
|
+
? zonedDateTimeToTimestamp(selectedDate, selectedFlexibleWindow.endTime, bookingTimezone)
|
|
1045
|
+
: selectedDate && selectedSlot
|
|
1046
|
+
? zonedDateTimeToTimestamp(selectedDate, selectedSlot.endTime, bookingTimezone)
|
|
1047
|
+
: newStartTime + selectedService.durationMinutes * 60 * 1000;
|
|
1048
|
+
const bookingNotes = isFlexibleRequest
|
|
1049
|
+
? appendFlexibleWindowNote({
|
|
1050
|
+
notes: customerNotes,
|
|
1051
|
+
dateKey: selectedDate,
|
|
1052
|
+
window: selectedFlexibleWindow,
|
|
1053
|
+
locale: intlLocale,
|
|
1054
|
+
timezone: bookingTimezone,
|
|
1055
|
+
t,
|
|
1056
|
+
})
|
|
1057
|
+
: customerNotes.trim() || undefined;
|
|
923
1058
|
// Reschedule mode bypasses the public-create flow entirely.
|
|
924
1059
|
// Service / staff / customer are already attached to the
|
|
925
1060
|
// original appointment, so the host (admin path) or magic-link
|
|
@@ -952,26 +1087,28 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
952
1087
|
const created = await client.createPublicBooking({
|
|
953
1088
|
siteSlug,
|
|
954
1089
|
serviceId: selectedServiceId,
|
|
955
|
-
staffMemberId:
|
|
956
|
-
? (
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1090
|
+
staffMemberId: isFlexibleRequest
|
|
1091
|
+
? (selectedStaffId ?? undefined)
|
|
1092
|
+
: selectedServiceRequiresSlot
|
|
1093
|
+
? (heldStaffId ??
|
|
1094
|
+
selectedStaffId ??
|
|
1095
|
+
selectedSlot?.availableStaffIds[0])
|
|
1096
|
+
: undefined,
|
|
960
1097
|
startTime: newStartTime,
|
|
961
1098
|
endTime: newEndTime,
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
setup?.workspaceTimezone ??
|
|
965
|
-
'UTC',
|
|
1099
|
+
isAnytime: isFlexibleRequest ? true : undefined,
|
|
1100
|
+
timezone: bookingTimezone,
|
|
966
1101
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
967
1102
|
customerName: customerName.trim(),
|
|
968
1103
|
customerEmail: customerEmail.trim(),
|
|
969
1104
|
customerPhone: customerPhone.trim() || undefined,
|
|
970
|
-
customerNotes:
|
|
1105
|
+
customerNotes: bookingNotes,
|
|
971
1106
|
intakeResponses,
|
|
972
|
-
quotedTotalCents:
|
|
973
|
-
bookingHoldId: selectedServiceRequiresSlot
|
|
974
|
-
|
|
1107
|
+
quotedTotalCents: displayQuote?.totalCents,
|
|
1108
|
+
bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
|
|
1109
|
+
? holdId
|
|
1110
|
+
: undefined,
|
|
1111
|
+
bookingSessionToken: selectedServiceRequiresSlot && !isFlexibleRequest
|
|
975
1112
|
? sessionToken
|
|
976
1113
|
: undefined,
|
|
977
1114
|
});
|
|
@@ -996,8 +1133,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
996
1133
|
service_name: pickLocaleField(selectedService, 'name', locale) ??
|
|
997
1134
|
selectedService.name,
|
|
998
1135
|
booking_mode: selectedService.publicBookingMode ?? 'scheduled',
|
|
1136
|
+
scheduling_preference: isFlexibleRequest ? 'flexible' : 'exact',
|
|
999
1137
|
requires_payment: selectedService.requiresPayment === true,
|
|
1000
|
-
quoted_total_cents:
|
|
1138
|
+
quoted_total_cents: displayQuote?.totalCents ?? null,
|
|
1001
1139
|
});
|
|
1002
1140
|
setSelectedSlot(null);
|
|
1003
1141
|
setPendingSlotKey(null);
|
|
@@ -1062,7 +1200,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1062
1200
|
? MOBILE_PROGRESS_STEPS_SCHEDULED
|
|
1063
1201
|
: MOBILE_PROGRESS_STEPS_ASYNC).map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })) : null] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsxs("div", { className: "bw-content", children: [_jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("div", { className: "bw-service-picker", children: selectedService && !isEditingService ? (_jsxs("div", { className: "bw-svc-selected", children: [_jsxs("div", { className: "bw-svc-selected-header", children: [_jsx("label", { className: "bw-label", children: t('selectedService') }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: t('btnChange') })] }), _jsxs("div", { className: "bw-svc-row bw-svc-row--readonly", children: [selectedService.image ? (_jsxs("span", { className: "bw-svc-image is-checked", children: [_jsx("img", { src: selectedService.image.url, alt: selectedService.image.alt ??
|
|
1064
1202
|
pickLocaleField(selectedService, 'name', locale) ??
|
|
1065
|
-
selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), (pickLocaleField(selectedService, 'description', locale) ?? selectedService.description) ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService,
|
|
1203
|
+
selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name }), (pickLocaleField(selectedService, 'description', locale) ?? selectedService.description) ? (_jsx("span", { className: "bw-svc-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { className: "bw-label", children: t('selectService') }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
|
|
1066
1204
|
const isActive = selectedServiceId === service._id;
|
|
1067
1205
|
return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
|
|
1068
1206
|
void prefetchAvailability({
|
|
@@ -1136,7 +1274,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1136
1274
|
cacheRef: availabilityCacheRef,
|
|
1137
1275
|
});
|
|
1138
1276
|
}
|
|
1139
|
-
}, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": t('nextMonth'), children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: [
|
|
1277
|
+
}, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": t('nextMonth'), children: _jsx(ChevronRightIcon, {}) })] })] }), schedulingPreferenceControl, _jsx("div", { className: "bw-cal-weekdays", children: [
|
|
1140
1278
|
t('weekdaySun'),
|
|
1141
1279
|
t('weekdayMon'),
|
|
1142
1280
|
t('weekdayTue'),
|
|
@@ -1147,9 +1285,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1147
1285
|
].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
|
|
1148
1286
|
const dateKey = formatDateKey(day);
|
|
1149
1287
|
const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
|
|
1150
|
-
const isPast = dateKey <
|
|
1288
|
+
const isPast = dateKey < todayDateKey;
|
|
1151
1289
|
const slots = filteredAvailabilityByDate.get(dateKey) ?? [];
|
|
1152
|
-
const
|
|
1290
|
+
const isExactAvailable = slots.length > 0;
|
|
1291
|
+
const isFlexibleAvailable = effectiveSchedulingPreference === 'flexible' &&
|
|
1292
|
+
selectedService != null &&
|
|
1293
|
+
isFlexibleDateSelectable({
|
|
1294
|
+
dateKey,
|
|
1295
|
+
service: selectedService,
|
|
1296
|
+
businessHours: setup?.businessHours ?? [],
|
|
1297
|
+
timezone: bookingTimezone,
|
|
1298
|
+
window: selectedFlexibleWindow,
|
|
1299
|
+
});
|
|
1300
|
+
const isAvailable = effectiveSchedulingPreference === 'flexible'
|
|
1301
|
+
? isFlexibleAvailable
|
|
1302
|
+
: isExactAvailable;
|
|
1153
1303
|
const isSelected = selectedDate === dateKey;
|
|
1154
1304
|
const className = [
|
|
1155
1305
|
'bw-cal-day',
|
|
@@ -1167,15 +1317,25 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1167
1317
|
.filter(Boolean)
|
|
1168
1318
|
.join(' ');
|
|
1169
1319
|
return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
|
|
1320
|
+
if (holdId) {
|
|
1321
|
+
void client
|
|
1322
|
+
.releasePublicBookingHold({
|
|
1323
|
+
siteSlug,
|
|
1324
|
+
holdId,
|
|
1325
|
+
sessionToken,
|
|
1326
|
+
})
|
|
1327
|
+
.catch(() => undefined);
|
|
1328
|
+
}
|
|
1170
1329
|
setSelectedDate(dateKey);
|
|
1171
1330
|
setSelectedSlot(null);
|
|
1331
|
+
setHoldId(null);
|
|
1332
|
+
setHoldExpiresAt(null);
|
|
1333
|
+
setHeldStaffId(null);
|
|
1172
1334
|
setMobileStep((current) => current < 2 ? 2 : current);
|
|
1173
1335
|
}, children: day.getDate() }, dateKey));
|
|
1174
|
-
}) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: t('calendarSelectServicePrompt') })) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children:
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
setup?.staff?.[0]?.timezone ??
|
|
1178
|
-
'UTC' })] })] }), selectedService && !isEditingService ? (_jsxs("div", { className: "bw-mobile-card bw-provider-section--mobile", children: [_jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null, selectedService && selectedDate ? (_jsxs("div", { className: "bw-mobile-card bw-slots-mobile", children: [_jsx("label", { className: "bw-label", children: t('selectTimePrompt') }), slotsArea] })) : null] }), _jsx("div", { className: "bw-col bw-col--review", "aria-hidden": mobileStep !== 4, children: selectedService ? (_jsxs("div", { className: "bw-summary bw-summary--review", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
|
|
1336
|
+
}) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: t('calendarSelectServicePrompt') })) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: bookingTimezone })] })] }), selectedService && !isEditingService ? (_jsxs("div", { className: "bw-mobile-card bw-provider-section--mobile", children: [_jsx("label", { className: "bw-label", children: t('selectProvider') }), providerRow] })) : null, selectedService && selectedDate ? (_jsxs("div", { className: "bw-mobile-card bw-slots-mobile", children: [_jsx("label", { className: "bw-label", children: effectiveSchedulingPreference === 'flexible'
|
|
1337
|
+
? t('flexWindowHeading')
|
|
1338
|
+
: t('selectTimePrompt') }), slotsArea] })) : null] }), _jsx("div", { className: "bw-col bw-col--review", "aria-hidden": mobileStep !== 4, children: selectedService ? (_jsxs("div", { className: "bw-summary bw-summary--review", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
|
|
1179
1339
|
? t('summaryTitleReschedule')
|
|
1180
1340
|
: t('summaryTitleCreate') }), holdSecondsRemaining !== null ? (_jsx("div", { className: "bw-summary-rows", children: _jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] }) })) : null, _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewBookingDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: {
|
|
1181
1341
|
...summaryValStyle,
|
|
@@ -1192,16 +1352,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1192
1352
|
}, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ??
|
|
1193
1353
|
selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ??
|
|
1194
1354
|
selectedStaff?.name ??
|
|
1195
|
-
t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null,
|
|
1355
|
+
t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] })] })] }), (() => {
|
|
1196
1356
|
const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
|
|
1197
1357
|
if (intakeRows.length === 0)
|
|
1198
1358
|
return null;
|
|
1199
1359
|
return (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewAdditionalInfo') }), _jsx("div", { className: "bw-summary-rows", children: intakeRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.multiline ? ' bw-summary-row--stack' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] }));
|
|
1200
1360
|
})(), customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
|
|
1201
1361
|
? t('notesLabelReschedule')
|
|
1202
|
-
: t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children:
|
|
1362
|
+
: t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
|
|
1203
1363
|
? t('summaryCalculating')
|
|
1204
|
-
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService,
|
|
1364
|
+
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, displayQuote, intlLocale) })] }) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: effectiveSchedulingPreference === 'flexible'
|
|
1365
|
+
? t('flexWindowHeading')
|
|
1366
|
+
: t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children: FLEXIBLE_WINDOW_OPTIONS.length })) : selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
|
|
1205
1367
|
? t('summaryTitleReschedule')
|
|
1206
1368
|
: t('summaryTitleCreate') }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: t('summaryReservationHold') }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: {
|
|
1207
1369
|
...summaryValStyle,
|
|
@@ -1217,12 +1379,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1217
1379
|
opacity: 0.55,
|
|
1218
1380
|
}, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ??
|
|
1219
1381
|
selectedStaff?.name ??
|
|
1220
|
-
t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null,
|
|
1382
|
+
t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
|
|
1221
1383
|
? t('summaryCalculating')
|
|
1222
|
-
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService,
|
|
1384
|
+
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] })] })) : null, error ? (_jsx("div", { className: "bw-error", children: error })) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
|
|
1223
1385
|
setSuccess(t('successPaymentReceived'));
|
|
1224
1386
|
setPayment(null);
|
|
1225
|
-
}, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit ||
|
|
1387
|
+
}, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isDisplayQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isDisplayQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
|
|
1226
1388
|
? isReschedule
|
|
1227
1389
|
? t('btnRescheduling')
|
|
1228
1390
|
: t('btnBooking')
|
|
@@ -1232,30 +1394,26 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1232
1394
|
? t('btnContinueToPayment')
|
|
1233
1395
|
: t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-details-view", children: [_jsx("div", { className: "bw-details-summary", children: selectedService ? (_jsxs(_Fragment, { children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsx("h3", { className: "bw-details-service", children: pickLocaleField(selectedService, 'name', locale) ??
|
|
1234
1396
|
selectedService.name }), (pickLocaleField(selectedService, 'description', locale) ??
|
|
1235
|
-
selectedService.description) ? (_jsx("p", { className: "bw-details-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryFormerTime') }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })] })) : null, selectedDate &&
|
|
1397
|
+
selectedService.description) ? (_jsx("p", { className: "bw-details-desc", children: pickLocaleField(selectedService, 'description', locale) ?? selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryFormerTime') }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime, intlLocale) })] })] })) : null, selectedDate && selectedTimeLabel ? (_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
|
|
1236
1398
|
? t('summaryNewTime')
|
|
1237
|
-
: t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}),
|
|
1399
|
+
: t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}), selectedTimeLabel] })] })] })) : 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 ??
|
|
1238
1400
|
selectedStaff?.name ??
|
|
1239
|
-
t('providerAny') })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children:
|
|
1240
|
-
selectedStaff?.timezone ??
|
|
1241
|
-
setup?.workspaceTimezone ??
|
|
1242
|
-
setup?.staff?.[0]?.timezone ??
|
|
1243
|
-
'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
|
|
1401
|
+
t('providerAny') })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: bookingTimezone })] }), _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: isDisplayQuoteLoading
|
|
1244
1402
|
? t('summaryCalculating')
|
|
1245
|
-
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService,
|
|
1403
|
+
: t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
|
|
1246
1404
|
? t('notesPlaceholderReschedule')
|
|
1247
1405
|
: t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
|
|
1248
1406
|
(forcedState === 'payment-full' ||
|
|
1249
1407
|
forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
|
|
1250
1408
|
setSuccess(t('successPaymentReceived'));
|
|
1251
1409
|
setPayment(null);
|
|
1252
|
-
}, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit ||
|
|
1410
|
+
}, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isDisplayQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isDisplayQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
|
|
1253
1411
|
? isReschedule
|
|
1254
1412
|
? t('btnRescheduling')
|
|
1255
1413
|
: t('btnBooking')
|
|
1256
1414
|
: forcedState === 'payment-full'
|
|
1257
1415
|
? t('btnPayAndConfirm', {
|
|
1258
|
-
price: formatPrice(selectedService, intlLocale),
|
|
1416
|
+
price: formatPrice(selectedService, intlLocale, displayQuote),
|
|
1259
1417
|
})
|
|
1260
1418
|
: forcedState === 'payment-deposit'
|
|
1261
1419
|
? t('btnPayDepositAndConfirm')
|
|
@@ -1279,7 +1437,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1279
1437
|
if (step === 1 && !selectedServiceRequiresSlot)
|
|
1280
1438
|
return 3;
|
|
1281
1439
|
return Math.min(4, step + 1);
|
|
1282
|
-
}), children: [_jsx("span", { children: t('btnNext') }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: `bw-footer-next${!canSubmit ||
|
|
1440
|
+
}), children: [_jsx("span", { children: t('btnNext') }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: `bw-footer-next${!canSubmit || isDisplayQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isDisplayQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
|
|
1283
1441
|
? isReschedule
|
|
1284
1442
|
? t('btnRescheduling')
|
|
1285
1443
|
: t('btnBooking')
|
|
@@ -1354,10 +1512,9 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
|
1354
1512
|
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1355
1513
|
}));
|
|
1356
1514
|
}
|
|
1357
|
-
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot,
|
|
1515
|
+
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
|
|
1358
1516
|
const blockers = [];
|
|
1359
|
-
if (selectedServiceRequiresSlot &&
|
|
1360
|
-
(!selectedDate || !selectedSlot || !holdId)) {
|
|
1517
|
+
if (selectedServiceRequiresSlot && !hasScheduleSelection) {
|
|
1361
1518
|
blockers.push(t('blockerDateTime'));
|
|
1362
1519
|
}
|
|
1363
1520
|
if (!customerName.trim())
|
|
@@ -1765,20 +1922,86 @@ function StripePaymentForm({ amountCents, currency, appointmentId, showInlineBut
|
|
|
1765
1922
|
function ServiceWarningCallout({ warning }) {
|
|
1766
1923
|
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] })] }));
|
|
1767
1924
|
}
|
|
1768
|
-
function formatPrice(service, locale = 'en-US') {
|
|
1925
|
+
function formatPrice(service, locale = 'en-US', quote) {
|
|
1769
1926
|
if (!service)
|
|
1770
1927
|
return '';
|
|
1771
|
-
return formatServicePrice(service,
|
|
1928
|
+
return formatServicePrice(service, quote, locale);
|
|
1772
1929
|
}
|
|
1773
1930
|
function formatServicePrice(service, quote, locale = 'en-US') {
|
|
1931
|
+
if (quote) {
|
|
1932
|
+
if (quote.displayLabel)
|
|
1933
|
+
return quote.displayLabel;
|
|
1934
|
+
return currencyFormatter(quote.currency, locale).format(quote.totalCents / 100);
|
|
1935
|
+
}
|
|
1774
1936
|
if (service.pricingMode === 'calculated') {
|
|
1775
|
-
if (quote) {
|
|
1776
|
-
return currencyFormatter(quote.currency, locale).format(quote.totalCents / 100);
|
|
1777
|
-
}
|
|
1778
1937
|
return 'Price calculated after details';
|
|
1779
1938
|
}
|
|
1780
1939
|
return currencyFormatter(service.currency, locale).format(service.priceCents / 100);
|
|
1781
1940
|
}
|
|
1941
|
+
function quoteFromOverride(service, override) {
|
|
1942
|
+
return {
|
|
1943
|
+
currency: service.currency || 'usd',
|
|
1944
|
+
subtotalCents: override.totalCents,
|
|
1945
|
+
discountCents: 0,
|
|
1946
|
+
insuranceCents: 0,
|
|
1947
|
+
additionalFeesCents: 0,
|
|
1948
|
+
totalCents: override.totalCents,
|
|
1949
|
+
rateSnapshot: {
|
|
1950
|
+
pricingMode: 'fixed',
|
|
1951
|
+
servicePriceCents: service.priceCents,
|
|
1952
|
+
quotedTotalCents: override.totalCents,
|
|
1953
|
+
quoteSource: 'host-estimator',
|
|
1954
|
+
},
|
|
1955
|
+
displayLabel: override.label,
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
function getInitialQuoteOverride(initialSelection) {
|
|
1959
|
+
if (!initialSelection)
|
|
1960
|
+
return null;
|
|
1961
|
+
const intake = initialSelection.intakeResponses;
|
|
1962
|
+
const totalCents = readCentsValue(initialSelection.quotedTotalCents) ??
|
|
1963
|
+
readCentsValue(intake?.estimate_cents);
|
|
1964
|
+
if (totalCents === null)
|
|
1965
|
+
return null;
|
|
1966
|
+
return {
|
|
1967
|
+
totalCents,
|
|
1968
|
+
label: readString(initialSelection.quotedTotalLabel) ??
|
|
1969
|
+
readString(intake?.estimate_label) ??
|
|
1970
|
+
undefined,
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
function readCentsValue(value) {
|
|
1974
|
+
const cents = typeof value === 'number'
|
|
1975
|
+
? value
|
|
1976
|
+
: typeof value === 'string'
|
|
1977
|
+
? Number.parseInt(value, 10)
|
|
1978
|
+
: Number.NaN;
|
|
1979
|
+
return Number.isFinite(cents) && Number.isInteger(cents) && cents >= 0
|
|
1980
|
+
? cents
|
|
1981
|
+
: null;
|
|
1982
|
+
}
|
|
1983
|
+
function initialSelectionMatchesService(initialSelection, service) {
|
|
1984
|
+
if (!initialSelection)
|
|
1985
|
+
return false;
|
|
1986
|
+
if (initialSelection.serviceId && initialSelection.serviceId === service._id) {
|
|
1987
|
+
return true;
|
|
1988
|
+
}
|
|
1989
|
+
if (initialSelection.serviceSlug &&
|
|
1990
|
+
initialSelection.serviceSlug === service.slug) {
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
function getApplicableInitialQuoteOverride(initialSelection, service, override) {
|
|
1996
|
+
if (!initialSelectionMatchesService(initialSelection, service))
|
|
1997
|
+
return null;
|
|
1998
|
+
if (override.totalCents < service.priceCents)
|
|
1999
|
+
return null;
|
|
2000
|
+
if (service.requiresPayment && override.totalCents !== service.priceCents) {
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
return override;
|
|
2004
|
+
}
|
|
1782
2005
|
function getServiceWarning(service, mode, locale = DEFAULT_LOCALE) {
|
|
1783
2006
|
if (!service || mode !== 'async')
|
|
1784
2007
|
return null;
|
|
@@ -1930,6 +2153,138 @@ function formatTimeLabel(time, locale = 'en-US') {
|
|
|
1930
2153
|
minute: '2-digit',
|
|
1931
2154
|
}).format(date);
|
|
1932
2155
|
}
|
|
2156
|
+
function formatFlexibleWindowTimeRange(window, locale = 'en-US') {
|
|
2157
|
+
return `${formatTimeLabel(window.startTime, locale)} – ${formatTimeLabel(window.endTime, locale)}`;
|
|
2158
|
+
}
|
|
2159
|
+
function appendFlexibleWindowNote({ notes, dateKey, window, locale, timezone, t, }) {
|
|
2160
|
+
const trimmed = notes.trim();
|
|
2161
|
+
const windowLabel = `${t(window.labelKey)} (${formatFlexibleWindowTimeRange(window, locale)})`;
|
|
2162
|
+
const line = `${t('flexRequestNotePrefix')}: ${formatReadableDate(dateKey, locale)}, ${windowLabel}, ${timezone}`;
|
|
2163
|
+
return trimmed ? `${trimmed}\n\n${line}` : line;
|
|
2164
|
+
}
|
|
2165
|
+
function resolveBookingTimezone({ workspaceTimezone, configuredTimezone, heldStaffTimezone, selectedStaffTimezone, fallbackStaffTimezone, }) {
|
|
2166
|
+
return normalizeWidgetTimezone(workspaceTimezone ??
|
|
2167
|
+
configuredTimezone ??
|
|
2168
|
+
heldStaffTimezone ??
|
|
2169
|
+
selectedStaffTimezone ??
|
|
2170
|
+
fallbackStaffTimezone ??
|
|
2171
|
+
getBrowserTimezone() ??
|
|
2172
|
+
'UTC');
|
|
2173
|
+
}
|
|
2174
|
+
function normalizeWidgetTimezone(value) {
|
|
2175
|
+
const candidate = value?.trim();
|
|
2176
|
+
if (!candidate)
|
|
2177
|
+
return 'UTC';
|
|
2178
|
+
try {
|
|
2179
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
2180
|
+
timeZone: candidate,
|
|
2181
|
+
}).resolvedOptions().timeZone;
|
|
2182
|
+
}
|
|
2183
|
+
catch {
|
|
2184
|
+
return 'UTC';
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
function getBrowserTimezone() {
|
|
2188
|
+
if (typeof Intl === 'undefined')
|
|
2189
|
+
return null;
|
|
2190
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
2191
|
+
}
|
|
2192
|
+
const timezoneFormatterCache = new Map();
|
|
2193
|
+
function getTimezoneFormatter(timezone) {
|
|
2194
|
+
const normalized = normalizeWidgetTimezone(timezone);
|
|
2195
|
+
const cached = timezoneFormatterCache.get(normalized);
|
|
2196
|
+
if (cached)
|
|
2197
|
+
return cached;
|
|
2198
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
2199
|
+
timeZone: normalized,
|
|
2200
|
+
year: 'numeric',
|
|
2201
|
+
month: '2-digit',
|
|
2202
|
+
day: '2-digit',
|
|
2203
|
+
hour: '2-digit',
|
|
2204
|
+
minute: '2-digit',
|
|
2205
|
+
second: '2-digit',
|
|
2206
|
+
hour12: false,
|
|
2207
|
+
});
|
|
2208
|
+
timezoneFormatterCache.set(normalized, formatter);
|
|
2209
|
+
return formatter;
|
|
2210
|
+
}
|
|
2211
|
+
function parseTimezoneParts(timestamp, timezone) {
|
|
2212
|
+
const parts = getTimezoneFormatter(timezone).formatToParts(new Date(timestamp));
|
|
2213
|
+
const values = {};
|
|
2214
|
+
for (const part of parts) {
|
|
2215
|
+
if (part.type === 'year' ||
|
|
2216
|
+
part.type === 'month' ||
|
|
2217
|
+
part.type === 'day' ||
|
|
2218
|
+
part.type === 'hour' ||
|
|
2219
|
+
part.type === 'minute' ||
|
|
2220
|
+
part.type === 'second') {
|
|
2221
|
+
values[part.type] = Number(part.value);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return {
|
|
2225
|
+
year: values.year ?? 0,
|
|
2226
|
+
month: values.month ?? 1,
|
|
2227
|
+
day: values.day ?? 1,
|
|
2228
|
+
hour: values.hour ?? 0,
|
|
2229
|
+
minute: values.minute ?? 0,
|
|
2230
|
+
second: values.second ?? 0,
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
function timezonePartsToUtcMs(parts) {
|
|
2234
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
2235
|
+
}
|
|
2236
|
+
function dateKeyInTimeZone(timestamp, timezone) {
|
|
2237
|
+
const parts = parseTimezoneParts(timestamp, timezone);
|
|
2238
|
+
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
|
|
2239
|
+
}
|
|
2240
|
+
function parseDateKeyParts(dateKey) {
|
|
2241
|
+
const [year, month, day] = dateKey.split('-').map(Number);
|
|
2242
|
+
return { year, month, day };
|
|
2243
|
+
}
|
|
2244
|
+
function zonedDateTimeToTimestamp(dateKey, time, timezone) {
|
|
2245
|
+
const { year, month, day } = parseDateKeyParts(dateKey);
|
|
2246
|
+
const [hour, minute] = time.split(':').map(Number);
|
|
2247
|
+
const target = {
|
|
2248
|
+
year,
|
|
2249
|
+
month,
|
|
2250
|
+
day,
|
|
2251
|
+
hour,
|
|
2252
|
+
minute,
|
|
2253
|
+
second: 0,
|
|
2254
|
+
};
|
|
2255
|
+
let guess = Date.UTC(year, month - 1, day, hour, minute, 0);
|
|
2256
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2257
|
+
const actual = parseTimezoneParts(guess, timezone);
|
|
2258
|
+
const offset = timezonePartsToUtcMs(actual) - guess;
|
|
2259
|
+
const adjusted = timezonePartsToUtcMs(target) - offset;
|
|
2260
|
+
if (adjusted === guess)
|
|
2261
|
+
return adjusted;
|
|
2262
|
+
guess = adjusted;
|
|
2263
|
+
}
|
|
2264
|
+
return guess;
|
|
2265
|
+
}
|
|
2266
|
+
function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, window, }) {
|
|
2267
|
+
if (!service)
|
|
2268
|
+
return false;
|
|
2269
|
+
if (!isBusinessOpenOnDate(dateKey, businessHours))
|
|
2270
|
+
return false;
|
|
2271
|
+
const now = Date.now();
|
|
2272
|
+
const windowEnd = zonedDateTimeToTimestamp(dateKey, window.endTime, timezone);
|
|
2273
|
+
const latestAllowedStart = now + (service.maxAdvanceBookingDays ?? 90) * 24 * 60 * 60 * 1000;
|
|
2274
|
+
if (windowEnd < now + (service.minimumNoticeMinutes ?? 0) * 60 * 1000) {
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2277
|
+
const windowStart = zonedDateTimeToTimestamp(dateKey, window.startTime, timezone);
|
|
2278
|
+
return windowStart <= latestAllowedStart;
|
|
2279
|
+
}
|
|
2280
|
+
function isBusinessOpenOnDate(dateKey, businessHours) {
|
|
2281
|
+
if (businessHours.length === 0)
|
|
2282
|
+
return true;
|
|
2283
|
+
const { year, month, day } = parseDateKeyParts(dateKey);
|
|
2284
|
+
const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
2285
|
+
const entry = businessHours.find((hours) => hours.day === dayOfWeek);
|
|
2286
|
+
return entry?.isOpen === true;
|
|
2287
|
+
}
|
|
1933
2288
|
function startOfMonth(date) {
|
|
1934
2289
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
1935
2290
|
}
|