@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.
@@ -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 || selectedService?.publicBookingMode === 'async') {
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 canAdvanceStep2 = Boolean(selectedDate && selectedSlot);
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 slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
710
- const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
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
- (!selectedServiceRequiresSlot ||
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
- selectedDate,
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 && !isQuoteLoading)
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: isQuoteLoading ? t('helpCalculating') : t('helpComplete') }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? (_jsx("li", { children: t('helpMoreFields', { count: hiddenCount }) })) : null] })) : null] }));
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: Date.parse(`${selectedDate}T${slot.time}:00`),
847
- endTime: Date.parse(`${selectedDate}T${slot.endTime}:00`),
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 || isQuoteLoading || isSubmitting) {
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
- (!selectedDate || !selectedSlot || !holdId))) {
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 newStartTime = selectedDate && selectedSlot
918
- ? Date.parse(`${selectedDate}T${selectedSlot.time}:00`)
919
- : Date.now();
920
- const newEndTime = selectedDate && selectedSlot
921
- ? Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`)
922
- : newStartTime + selectedService.durationMinutes * 60 * 1000;
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: selectedServiceRequiresSlot
956
- ? (heldStaffId ??
957
- selectedStaffId ??
958
- selectedSlot?.availableStaffIds[0])
959
- : undefined,
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
- timezone: selectedHeldStaff?.timezone ??
963
- availableStaff[0]?.timezone ??
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: customerNotes.trim() || undefined,
1105
+ customerNotes: bookingNotes,
971
1106
  intakeResponses,
972
- quotedTotalCents: quote?.totalCents,
973
- bookingHoldId: selectedServiceRequiresSlot ? holdId : undefined,
974
- bookingSessionToken: selectedServiceRequiresSlot
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: quote?.totalCents ?? null,
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, undefined, 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) => {
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 < formatDateKey(startOfDay(new Date()));
1288
+ const isPast = dateKey < todayDateKey;
1151
1289
  const slots = filteredAvailabilityByDate.get(dateKey) ?? [];
1152
- const isAvailable = slots.length > 0;
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: selectedHeldStaff?.timezone ??
1175
- selectedStaff?.timezone ??
1176
- setup?.workspaceTimezone ??
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, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] })] })] }), (() => {
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: isQuoteLoading
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, quote, intlLocale) })] }) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: t('slotsHeading') }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
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, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time, intlLocale) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading
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, quote, intlLocale) })] })] })] })) : null, error ? (_jsx("div", { className: "bw-error", children: error })) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
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 || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
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 && 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
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", {}), formatTimeLabel(selectedSlot.time, intlLocale), " \u2013", ' ', formatTimeLabel(selectedSlot.endTime, intlLocale)] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ??
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: selectedHeldStaff?.timezone ??
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, quote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
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 || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
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 || isQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
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, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
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, undefined, locale);
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
  }