@asksable/site-connector 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +420 -47
- package/dist/booking-widget.js.map +1 -1
- package/dist/styles.css +134 -0
- package/dist/translations.d.ts +36 -4
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +37 -5
- package/dist/translations.js.map +1 -1
- package/package.json +1 -1
package/dist/booking-widget.js
CHANGED
|
@@ -11,29 +11,31 @@ 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
|
|
14
|
+
const FLEXIBLE_WINDOW_TEMPLATES = [
|
|
15
15
|
{
|
|
16
16
|
id: 'all-day',
|
|
17
|
-
startTime: '09:00',
|
|
18
|
-
endTime: '17:00',
|
|
19
17
|
labelKey: 'flexWindowAllDay',
|
|
20
18
|
descriptionKey: 'flexWindowAllDayDesc',
|
|
21
19
|
},
|
|
22
20
|
{
|
|
23
21
|
id: 'morning',
|
|
24
|
-
startTime: '09:00',
|
|
25
|
-
endTime: '12:00',
|
|
26
22
|
labelKey: 'flexWindowMorning',
|
|
27
23
|
descriptionKey: 'flexWindowMorningDesc',
|
|
28
24
|
},
|
|
29
25
|
{
|
|
30
26
|
id: 'afternoon',
|
|
31
|
-
startTime: '12:00',
|
|
32
|
-
endTime: '17:00',
|
|
33
27
|
labelKey: 'flexWindowAfternoon',
|
|
34
28
|
descriptionKey: 'flexWindowAfternoonDesc',
|
|
35
29
|
},
|
|
36
30
|
];
|
|
31
|
+
const DEFAULT_FLEXIBLE_OPEN_TIME = '09:00';
|
|
32
|
+
const DEFAULT_FLEXIBLE_CLOSE_TIME = '17:00';
|
|
33
|
+
const NOON_MINUTES = 12 * 60;
|
|
34
|
+
const MIN_FLEXIBLE_WINDOW_MINUTES = 30;
|
|
35
|
+
const MAX_BOOKING_PHOTOS = 5;
|
|
36
|
+
const MAX_BOOKING_PHOTO_BASE64_LENGTH = 1_500_000;
|
|
37
|
+
const MAX_BOOKING_PHOTO_EDGE = 1600;
|
|
38
|
+
const BOOKING_PHOTO_JPEG_QUALITY = 0.78;
|
|
37
39
|
const stripePromises = new Map();
|
|
38
40
|
function getStripePromise(publishableKey, connectAccountId) {
|
|
39
41
|
const key = `${publishableKey}:${connectAccountId}`;
|
|
@@ -95,7 +97,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
95
97
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
96
98
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
97
99
|
const [schedulingPreference, setSchedulingPreference] = useState(() => defaultSchedulingPreference === 'flexible' ? 'flexible' : 'exact');
|
|
98
|
-
const [selectedFlexibleWindowId, setSelectedFlexibleWindowId] = useState(
|
|
100
|
+
const [selectedFlexibleWindowId, setSelectedFlexibleWindowId] = useState('all-day');
|
|
99
101
|
const [pendingSlotKey, setPendingSlotKey] = useState(null);
|
|
100
102
|
// Service picker is progressive: full list shows initially, collapses
|
|
101
103
|
// to a single "selected service" card once a service is picked, then
|
|
@@ -113,7 +115,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
113
115
|
const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
|
|
114
116
|
const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
|
|
115
117
|
const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
|
|
118
|
+
const [serviceAddress, setServiceAddress] = useState('');
|
|
116
119
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
120
|
+
const [bookingPhotos, setBookingPhotos] = useState([]);
|
|
121
|
+
const [isPreparingPhotos, setIsPreparingPhotos] = useState(false);
|
|
122
|
+
const [photoError, setPhotoError] = useState(null);
|
|
117
123
|
const [intakeResponses, setIntakeResponses] = useState(() => initialSelection?.intakeResponses ?? {});
|
|
118
124
|
const initialSelectionAppliedRef = useRef(false);
|
|
119
125
|
const previousSelectedServiceIdRef = useRef(selectedServiceId);
|
|
@@ -273,16 +279,34 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
273
279
|
? quoteFromOverride(selectedService, selectedQuoteOverride)
|
|
274
280
|
: quote;
|
|
275
281
|
const isDisplayQuoteLoading = isQuoteLoading && !displayQuote;
|
|
282
|
+
const bookingContextRows = useMemo(() => buildBookingContextRows({
|
|
283
|
+
intakeResponses,
|
|
284
|
+
quote: displayQuote,
|
|
285
|
+
locale: intlLocale,
|
|
286
|
+
}), [displayQuote, intakeResponses, intlLocale]);
|
|
276
287
|
const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
|
|
277
288
|
const flexibleSchedulingEnabled = allowFlexibleScheduling && selectedServiceRequiresSlot && !isReschedule;
|
|
278
289
|
const effectiveSchedulingPreference = flexibleSchedulingEnabled ? schedulingPreference : 'exact';
|
|
279
|
-
const selectedFlexibleWindow = FLEXIBLE_WINDOW_OPTIONS.find((option) => option.id === selectedFlexibleWindowId) ?? FLEXIBLE_WINDOW_OPTIONS[0];
|
|
280
290
|
const bookingTimezone = resolveBookingTimezone({
|
|
281
291
|
workspaceTimezone: setup?.workspaceTimezone,
|
|
282
292
|
configuredTimezone,
|
|
283
293
|
fallbackStaffTimezone: setup?.staff?.[0]?.timezone,
|
|
284
294
|
});
|
|
285
295
|
const todayDateKey = dateKeyInTimeZone(Date.now(), bookingTimezone);
|
|
296
|
+
const selectedDateFlexibleWindows = useMemo(() => selectedDate
|
|
297
|
+
? getFlexibleWindowOptionsForDate({
|
|
298
|
+
dateKey: selectedDate,
|
|
299
|
+
businessHours: setup?.businessHours ?? [],
|
|
300
|
+
}).filter((option) => isFlexibleWindowSelectable({
|
|
301
|
+
dateKey: selectedDate,
|
|
302
|
+
service: selectedService,
|
|
303
|
+
timezone: bookingTimezone,
|
|
304
|
+
window: option,
|
|
305
|
+
}))
|
|
306
|
+
: [], [bookingTimezone, selectedDate, selectedService, setup?.businessHours]);
|
|
307
|
+
const selectedFlexibleWindow = selectedDateFlexibleWindows.find((option) => option.id === selectedFlexibleWindowId) ??
|
|
308
|
+
selectedDateFlexibleWindows[0] ??
|
|
309
|
+
null;
|
|
286
310
|
const selectedServicePath = selectedServiceRequiresSlot
|
|
287
311
|
? 'scheduled'
|
|
288
312
|
: 'async';
|
|
@@ -290,10 +314,28 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
290
314
|
const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
|
|
291
315
|
areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
|
|
292
316
|
const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath, locale), [selectedService, selectedServicePath, locale]);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (effectiveSchedulingPreference !== 'flexible')
|
|
319
|
+
return;
|
|
320
|
+
if (selectedDateFlexibleWindows.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
if (selectedDateFlexibleWindows.some((option) => option.id === selectedFlexibleWindowId)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
setSelectedFlexibleWindowId(selectedDateFlexibleWindows[0].id);
|
|
326
|
+
}, [
|
|
327
|
+
effectiveSchedulingPreference,
|
|
328
|
+
selectedDateFlexibleWindows,
|
|
329
|
+
selectedFlexibleWindowId,
|
|
330
|
+
]);
|
|
293
331
|
const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
|
|
294
332
|
const trimmedCustomerEmail = customerEmail.trim();
|
|
333
|
+
const trimmedCustomerPhone = customerPhone.trim();
|
|
334
|
+
const trimmedServiceAddress = serviceAddress.trim();
|
|
295
335
|
const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
|
|
296
336
|
const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
|
|
337
|
+
const showCustomerPhoneError = submitAttempted && !trimmedCustomerPhone;
|
|
338
|
+
const showServiceAddressError = submitAttempted && !isReschedule && !trimmedServiceAddress;
|
|
297
339
|
// In reschedule mode we pre-select the original service but keep
|
|
298
340
|
// the full list visible - the admin may legitimately need to
|
|
299
341
|
// switch service when rescheduling (e.g. customer asked for a
|
|
@@ -395,15 +437,24 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
395
437
|
if (effectiveSchedulingPreference === 'flexible') {
|
|
396
438
|
setAvailabilityByDate(new Map());
|
|
397
439
|
setIsAvailabilityLoading(false);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
setSelectedDate(
|
|
440
|
+
const firstAvailableDate = findFirstSelectableFlexibleDate({
|
|
441
|
+
calendarMonth,
|
|
442
|
+
todayDateKey,
|
|
443
|
+
service: selectedService,
|
|
444
|
+
businessHours: setup?.businessHours ?? [],
|
|
445
|
+
timezone: bookingTimezone,
|
|
446
|
+
});
|
|
447
|
+
if (!selectedDate) {
|
|
448
|
+
setSelectedDate(firstAvailableDate);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!hasSelectableFlexibleWindowsForDate({
|
|
452
|
+
dateKey: selectedDate,
|
|
453
|
+
service: selectedService,
|
|
454
|
+
businessHours: setup?.businessHours ?? [],
|
|
455
|
+
timezone: bookingTimezone,
|
|
456
|
+
})) {
|
|
457
|
+
setSelectedDate(firstAvailableDate);
|
|
407
458
|
}
|
|
408
459
|
return;
|
|
409
460
|
}
|
|
@@ -483,10 +534,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
483
534
|
selectedService?.publicBookingMode,
|
|
484
535
|
selectedServiceId,
|
|
485
536
|
selectedStaffId,
|
|
486
|
-
selectedFlexibleWindow,
|
|
487
537
|
selectedService,
|
|
488
538
|
setup?.businessHours,
|
|
489
539
|
siteSlug,
|
|
540
|
+
todayDateKey,
|
|
490
541
|
]);
|
|
491
542
|
useEffect(() => {
|
|
492
543
|
if (!selectedServiceId ||
|
|
@@ -756,7 +807,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
756
807
|
(effectiveSchedulingPreference === 'flexible'
|
|
757
808
|
? Boolean(selectedDate && selectedFlexibleWindow)
|
|
758
809
|
: Boolean(selectedDate && selectedSlot && holdId));
|
|
759
|
-
const selectedTimeLabel = selectedDate &&
|
|
810
|
+
const selectedTimeLabel = selectedDate &&
|
|
811
|
+
effectiveSchedulingPreference === 'flexible' &&
|
|
812
|
+
selectedFlexibleWindow
|
|
760
813
|
? formatFlexibleWindowTimeRange(selectedFlexibleWindow, intlLocale)
|
|
761
814
|
: selectedSlot
|
|
762
815
|
? `${formatTimeLabel(selectedSlot.time, intlLocale)} – ${formatTimeLabel(selectedSlot.endTime, intlLocale)}`
|
|
@@ -765,6 +818,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
765
818
|
const canAdvanceStep3 = Boolean(selectedService &&
|
|
766
819
|
customerName.trim() &&
|
|
767
820
|
isCustomerEmailValid &&
|
|
821
|
+
(isReschedule || (trimmedCustomerPhone && trimmedServiceAddress)) &&
|
|
768
822
|
isIntakeComplete);
|
|
769
823
|
// Slots block is rendered in two locations: inline under the calendar
|
|
770
824
|
// (mobile flow keeps current step-3 behavior) and at the top of the
|
|
@@ -793,10 +847,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
793
847
|
})] })) : null;
|
|
794
848
|
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
849
|
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 ? (
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
850
|
+
const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : effectiveSchedulingPreference === 'flexible' ? (selectedDate ? (_jsxs("div", { className: "bw-window-options", children: [selectedDateFlexibleWindows.map((option, index) => {
|
|
851
|
+
const isActive = selectedFlexibleWindowId === option.id;
|
|
852
|
+
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));
|
|
853
|
+
}), selectedDateFlexibleWindows.length === 0 ? (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') })) : null] })) : (_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) => {
|
|
800
854
|
const slotKey = `${slot.time}-${slot.endTime}`;
|
|
801
855
|
const isPending = pendingSlotKey === slotKey;
|
|
802
856
|
const isActive = isPending ||
|
|
@@ -811,7 +865,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
811
865
|
isIntakeComplete &&
|
|
812
866
|
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
813
867
|
customerName.trim() &&
|
|
814
|
-
isCustomerEmailValid
|
|
868
|
+
isCustomerEmailValid &&
|
|
869
|
+
(isReschedule || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
|
|
815
870
|
const submitBlockers = selectedService
|
|
816
871
|
? getSubmitBlockers({
|
|
817
872
|
selectedService,
|
|
@@ -823,6 +878,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
823
878
|
quote,
|
|
824
879
|
customerName,
|
|
825
880
|
customerEmail,
|
|
881
|
+
customerPhone,
|
|
882
|
+
serviceAddress,
|
|
883
|
+
requireServiceAddress: !isReschedule,
|
|
826
884
|
t,
|
|
827
885
|
locale,
|
|
828
886
|
})
|
|
@@ -830,9 +888,47 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
830
888
|
function handleNotesInput(event) {
|
|
831
889
|
setCustomerNotes(event.currentTarget.value);
|
|
832
890
|
}
|
|
891
|
+
async function handlePhotoInput(event) {
|
|
892
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
893
|
+
event.currentTarget.value = '';
|
|
894
|
+
if (files.length === 0)
|
|
895
|
+
return;
|
|
896
|
+
setPhotoError(null);
|
|
897
|
+
setIsPreparingPhotos(true);
|
|
898
|
+
try {
|
|
899
|
+
const remaining = Math.max(0, MAX_BOOKING_PHOTOS - bookingPhotos.length);
|
|
900
|
+
const accepted = files.slice(0, remaining);
|
|
901
|
+
const prepared = [];
|
|
902
|
+
for (const file of accepted) {
|
|
903
|
+
prepared.push(await prepareBookingPhoto(file));
|
|
904
|
+
}
|
|
905
|
+
setBookingPhotos((current) => [...current, ...prepared].slice(0, MAX_BOOKING_PHOTOS));
|
|
906
|
+
if (files.length > remaining) {
|
|
907
|
+
setPhotoError(t('photosLimit', { count: MAX_BOOKING_PHOTOS }));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
catch (nextError) {
|
|
911
|
+
setPhotoError(nextError instanceof Error ? nextError.message : t('photosFailed'));
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
setIsPreparingPhotos(false);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
833
917
|
function renderNotesField(id, label, placeholder) {
|
|
834
918
|
return (_jsxs("div", { className: "bw-field bw-field--notes", children: [_jsx("label", { htmlFor: id, children: label }), _jsx("textarea", { id: id, rows: 3, maxLength: 500, value: customerNotes, onChange: handleNotesInput, onInput: handleNotesInput, placeholder: placeholder }), _jsxs("span", { className: "bw-char-count", "aria-live": "polite", children: [customerNotes.length, "/500"] })] }));
|
|
835
919
|
}
|
|
920
|
+
function renderBookingContext() {
|
|
921
|
+
if (bookingContextRows.length === 0)
|
|
922
|
+
return null;
|
|
923
|
+
return (_jsxs("div", { className: "bw-booking-context", children: [_jsx("span", { className: "bw-booking-context-title", children: t('bookingContextTitle') }), _jsx("div", { className: "bw-booking-context-rows", children: bookingContextRows.map((row) => (_jsxs("div", { className: "bw-booking-context-row", children: [_jsx("span", { children: row.label }), _jsx("span", { children: row.value })] }, row.key))) })] }));
|
|
924
|
+
}
|
|
925
|
+
function renderPhotoField(id) {
|
|
926
|
+
if (isReschedule)
|
|
927
|
+
return null;
|
|
928
|
+
return (_jsxs("div", { className: "bw-photo-field", children: [_jsxs("div", { className: "bw-photo-head", children: [_jsxs("div", { children: [_jsx("span", { className: "bw-photo-title", children: t('photosLabel') }), _jsx("p", { children: t('photosHelp') })] }), _jsx("label", { className: "bw-photo-button", htmlFor: id, children: isPreparingPhotos ? t('photosPreparing') : t('photosAdd') }), _jsx("input", { id: id, type: "file", accept: "image/*", multiple: true, onChange: (event) => void handlePhotoInput(event), disabled: isPreparingPhotos || bookingPhotos.length >= MAX_BOOKING_PHOTOS })] }), photoError ? _jsx("p", { className: "bw-photo-error", children: photoError }) : null, bookingPhotos.length > 0 ? (_jsx("div", { className: "bw-photo-list", children: bookingPhotos.map((photo) => (_jsxs("div", { className: "bw-photo-item", children: [_jsx("span", { children: photo.filename }), _jsx("button", { type: "button", onClick: () => setBookingPhotos((current) => current.filter((item) => item.id !== photo.id)), "aria-label": t('photosRemoveAria', {
|
|
929
|
+
name: photo.filename,
|
|
930
|
+
}), children: t('photosRemove') })] }, photo.id))) })) : null] }));
|
|
931
|
+
}
|
|
836
932
|
function renderSubmitHelp() {
|
|
837
933
|
if (!selectedService || canSubmit || payment || isSubmitting)
|
|
838
934
|
return null;
|
|
@@ -852,8 +948,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
852
948
|
const nameId = `bw-name${idSuffix}`;
|
|
853
949
|
const emailId = `bw-email${idSuffix}`;
|
|
854
950
|
const phoneId = `bw-phone${idSuffix}`;
|
|
951
|
+
const addressId = `bw-service-address${idSuffix}`;
|
|
855
952
|
const emailErrorId = `${emailId}-error`;
|
|
856
|
-
|
|
953
|
+
const phoneErrorId = `${phoneId}-error`;
|
|
954
|
+
const addressErrorId = `${addressId}-error`;
|
|
955
|
+
return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: [t('contactFullName'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: t('placeholderFullName') })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: [t('contactEmail'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: t('placeholderEmail') }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: t('contactEmailInvalid') })) : null] }), _jsxs("div", { className: `bw-field${showCustomerPhoneError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: phoneId, children: [t('contactPhone'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: phoneId, type: "tel", required: true, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), "aria-invalid": showCustomerPhoneError, "aria-describedby": showCustomerPhoneError ? phoneErrorId : undefined, placeholder: t('placeholderPhone') }), showCustomerPhoneError ? (_jsx("span", { className: "bw-field-error", id: phoneErrorId, children: t('contactPhoneRequired') })) : null] }), !isReschedule ? (_jsxs("div", { className: `bw-field bw-field--wide${showServiceAddressError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: addressId, children: [t('contactServiceAddress'), ' ', _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: addressId, required: true, value: serviceAddress, onChange: (event) => setServiceAddress(event.target.value), "aria-invalid": showServiceAddressError, "aria-describedby": showServiceAddressError ? addressErrorId : undefined, placeholder: t('placeholderServiceAddress') }), showServiceAddressError ? (_jsx("span", { className: "bw-field-error", id: addressErrorId, children: t('contactServiceAddressRequired') })) : null] })) : null] }));
|
|
857
956
|
}
|
|
858
957
|
function renderIntakeFields(idPrefix) {
|
|
859
958
|
return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
|
|
@@ -1045,16 +1144,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1045
1144
|
: selectedDate && selectedSlot
|
|
1046
1145
|
? zonedDateTimeToTimestamp(selectedDate, selectedSlot.endTime, bookingTimezone)
|
|
1047
1146
|
: newStartTime + selectedService.durationMinutes * 60 * 1000;
|
|
1147
|
+
const bookingBaseNotes = formatBookingNotesForSubmit({
|
|
1148
|
+
contextRows: bookingContextRows,
|
|
1149
|
+
additionalNotes: customerNotes,
|
|
1150
|
+
t,
|
|
1151
|
+
});
|
|
1048
1152
|
const bookingNotes = isFlexibleRequest
|
|
1049
1153
|
? appendFlexibleWindowNote({
|
|
1050
|
-
notes:
|
|
1154
|
+
notes: bookingBaseNotes,
|
|
1051
1155
|
dateKey: selectedDate,
|
|
1052
1156
|
window: selectedFlexibleWindow,
|
|
1053
1157
|
locale: intlLocale,
|
|
1054
1158
|
timezone: bookingTimezone,
|
|
1055
1159
|
t,
|
|
1056
1160
|
})
|
|
1057
|
-
:
|
|
1161
|
+
: bookingBaseNotes || undefined;
|
|
1058
1162
|
// Reschedule mode bypasses the public-create flow entirely.
|
|
1059
1163
|
// Service / staff / customer are already attached to the
|
|
1060
1164
|
// original appointment, so the host (admin path) or magic-link
|
|
@@ -1101,9 +1205,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1101
1205
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
1102
1206
|
customerName: customerName.trim(),
|
|
1103
1207
|
customerEmail: customerEmail.trim(),
|
|
1104
|
-
customerPhone:
|
|
1208
|
+
customerPhone: trimmedCustomerPhone,
|
|
1105
1209
|
customerNotes: bookingNotes,
|
|
1210
|
+
location: trimmedServiceAddress,
|
|
1106
1211
|
intakeResponses,
|
|
1212
|
+
photos: bookingPhotos.map((photo) => ({
|
|
1213
|
+
filename: photo.filename,
|
|
1214
|
+
data: photo.data,
|
|
1215
|
+
})),
|
|
1107
1216
|
quotedTotalCents: displayQuote?.totalCents,
|
|
1108
1217
|
bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
|
|
1109
1218
|
? holdId
|
|
@@ -1145,7 +1254,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1145
1254
|
setCustomerName('');
|
|
1146
1255
|
setCustomerEmail('');
|
|
1147
1256
|
setCustomerPhone('');
|
|
1257
|
+
setServiceAddress('');
|
|
1148
1258
|
setCustomerNotes('');
|
|
1259
|
+
setBookingPhotos([]);
|
|
1260
|
+
setPhotoError(null);
|
|
1149
1261
|
setIntakeResponses({});
|
|
1150
1262
|
setQuote(null);
|
|
1151
1263
|
setPayment(null);
|
|
@@ -1290,12 +1402,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1290
1402
|
const isExactAvailable = slots.length > 0;
|
|
1291
1403
|
const isFlexibleAvailable = effectiveSchedulingPreference === 'flexible' &&
|
|
1292
1404
|
selectedService != null &&
|
|
1293
|
-
|
|
1405
|
+
hasSelectableFlexibleWindowsForDate({
|
|
1294
1406
|
dateKey,
|
|
1295
1407
|
service: selectedService,
|
|
1296
1408
|
businessHours: setup?.businessHours ?? [],
|
|
1297
1409
|
timezone: bookingTimezone,
|
|
1298
|
-
window: selectedFlexibleWindow,
|
|
1299
1410
|
});
|
|
1300
1411
|
const isAvailable = effectiveSchedulingPreference === 'flexible'
|
|
1301
1412
|
? isFlexibleAvailable
|
|
@@ -1352,18 +1463,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1352
1463
|
}, 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) ??
|
|
1353
1464
|
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 ??
|
|
1354
1465
|
selectedStaff?.name ??
|
|
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') })] })] })] }), (() => {
|
|
1466
|
+
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') })] }), !isReschedule ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--stack", children: [_jsx("span", { children: t('contactServiceAddress') }), _jsx("span", { className: "bw-summary-val", children: serviceAddress.trim() || t('reviewNotProvided') })] })) : null] })] }), (() => {
|
|
1356
1467
|
const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
|
|
1357
1468
|
if (intakeRows.length === 0)
|
|
1358
1469
|
return null;
|
|
1359
1470
|
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))) })] }));
|
|
1360
|
-
})(), customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
|
|
1471
|
+
})(), bookingContextRows.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('bookingContextTitle') }), _jsx("div", { className: "bw-summary-rows", children: bookingContextRows.map((row) => (_jsxs("div", { className: "bw-summary-row bw-summary-row--stack", children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] })) : null, customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
|
|
1361
1472
|
? t('notesLabelReschedule')
|
|
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
|
|
1473
|
+
: t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, !isReschedule && bookingPhotos.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('photosLabel') }), _jsx("p", { className: "bw-summary-notes", children: t('photosCount', { count: bookingPhotos.length }) })] })) : 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
|
|
1363
1474
|
? t('summaryCalculating')
|
|
1364
1475
|
: 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
1476
|
? t('flexWindowHeading')
|
|
1366
|
-
: t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children:
|
|
1477
|
+
: t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children: selectedDateFlexibleWindows.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', ''), renderBookingContext(), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), renderPhotoField('bw-photos'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
|
|
1367
1478
|
? t('summaryTitleReschedule')
|
|
1368
1479
|
: 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: {
|
|
1369
1480
|
...summaryValStyle,
|
|
@@ -1398,11 +1509,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1398
1509
|
? t('summaryNewTime')
|
|
1399
1510
|
: 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 ??
|
|
1400
1511
|
selectedStaff?.name ??
|
|
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
|
|
1512
|
+
t('providerAny') })] }), !isReschedule && serviceAddress.trim() ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(LocationPinIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: serviceAddress.trim() })] })) : null, _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
|
|
1402
1513
|
? t('summaryCalculating')
|
|
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
|
|
1514
|
+
: 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'), renderBookingContext(), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
|
|
1404
1515
|
? t('notesPlaceholderReschedule')
|
|
1405
|
-
: t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
|
|
1516
|
+
: t('notesPlaceholder')), renderPhotoField('bw-photos-d'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
|
|
1406
1517
|
(forcedState === 'payment-full' ||
|
|
1407
1518
|
forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
|
|
1408
1519
|
setSuccess(t('successPaymentReceived'));
|
|
@@ -1512,7 +1623,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
|
1512
1623
|
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1513
1624
|
}));
|
|
1514
1625
|
}
|
|
1515
|
-
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
|
|
1626
|
+
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
|
|
1516
1627
|
const blockers = [];
|
|
1517
1628
|
if (selectedServiceRequiresSlot && !hasScheduleSelection) {
|
|
1518
1629
|
blockers.push(t('blockerDateTime'));
|
|
@@ -1525,6 +1636,11 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
|
|
|
1525
1636
|
else if (!isValidEmailAddress(customerEmail)) {
|
|
1526
1637
|
blockers.push(t('blockerContactEmailInvalid'));
|
|
1527
1638
|
}
|
|
1639
|
+
if (!customerPhone.trim())
|
|
1640
|
+
blockers.push(t('blockerContactPhone'));
|
|
1641
|
+
if (requireServiceAddress && !serviceAddress.trim()) {
|
|
1642
|
+
blockers.push(t('blockerServiceAddress'));
|
|
1643
|
+
}
|
|
1528
1644
|
if (!isIntakeComplete && selectedService.intakeForm) {
|
|
1529
1645
|
blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
|
|
1530
1646
|
}
|
|
@@ -1671,6 +1787,123 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
|
|
|
1671
1787
|
}
|
|
1672
1788
|
return rows;
|
|
1673
1789
|
}
|
|
1790
|
+
function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
1791
|
+
const rows = [];
|
|
1792
|
+
const usedKeys = new Set();
|
|
1793
|
+
const addKnown = (keys, label, formatter = formatContextValue) => {
|
|
1794
|
+
for (const key of keys) {
|
|
1795
|
+
const value = intakeResponses[key];
|
|
1796
|
+
const formatted = formatter(value);
|
|
1797
|
+
if (formatted) {
|
|
1798
|
+
rows.push({ key, label, value: formatted });
|
|
1799
|
+
usedKeys.add(key);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
addKnown(['vehicle_size', 'vehicleSize', 'vehicle', 'vehicle_type', 'vehicleType'], 'Vehicle', formatVehicleContextValue);
|
|
1805
|
+
addKnown(['pet_hair', 'petHair', 'pet_hair_level'], 'Pet hair');
|
|
1806
|
+
addKnown([
|
|
1807
|
+
'stains',
|
|
1808
|
+
'stain_removal',
|
|
1809
|
+
'stainRemoval',
|
|
1810
|
+
'shampooing',
|
|
1811
|
+
'shampoo_needed',
|
|
1812
|
+
'shampooNeeded',
|
|
1813
|
+
'stains_or_shampooing',
|
|
1814
|
+
], 'Stains or shampooing');
|
|
1815
|
+
const estimateCents = readCentsValue(intakeResponses.estimate_cents) ??
|
|
1816
|
+
readCentsValue(intakeResponses.estimateCents) ??
|
|
1817
|
+
quote?.totalCents ??
|
|
1818
|
+
null;
|
|
1819
|
+
if (estimateCents !== null) {
|
|
1820
|
+
rows.push({
|
|
1821
|
+
key: 'estimate_cents',
|
|
1822
|
+
label: 'Website estimate',
|
|
1823
|
+
value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
|
|
1824
|
+
});
|
|
1825
|
+
usedKeys.add('estimate_cents');
|
|
1826
|
+
usedKeys.add('estimateCents');
|
|
1827
|
+
}
|
|
1828
|
+
for (const [key, value] of Object.entries(intakeResponses)) {
|
|
1829
|
+
if (usedKeys.has(key))
|
|
1830
|
+
continue;
|
|
1831
|
+
if (key === 'estimate_label' || key === 'estimateLabel')
|
|
1832
|
+
continue;
|
|
1833
|
+
const formatted = formatContextValue(value);
|
|
1834
|
+
if (!formatted)
|
|
1835
|
+
continue;
|
|
1836
|
+
rows.push({
|
|
1837
|
+
key,
|
|
1838
|
+
label: formatContextKey(key),
|
|
1839
|
+
value: formatted,
|
|
1840
|
+
});
|
|
1841
|
+
if (rows.length >= 8)
|
|
1842
|
+
break;
|
|
1843
|
+
}
|
|
1844
|
+
return rows;
|
|
1845
|
+
}
|
|
1846
|
+
function formatVehicleContextValue(value) {
|
|
1847
|
+
const raw = formatContextValue(value);
|
|
1848
|
+
if (!raw)
|
|
1849
|
+
return null;
|
|
1850
|
+
const normalized = raw.toLowerCase().replace(/[\s_-]+/g, '-');
|
|
1851
|
+
const labels = {
|
|
1852
|
+
car: 'Car or small SUV',
|
|
1853
|
+
small: 'Car or small SUV',
|
|
1854
|
+
sedan: 'Car or small SUV',
|
|
1855
|
+
'small-suv': 'Car or small SUV',
|
|
1856
|
+
large: 'Large or 3-row',
|
|
1857
|
+
'3-row': 'Large or 3-row',
|
|
1858
|
+
'three-row': 'Large or 3-row',
|
|
1859
|
+
suv: 'Large or 3-row',
|
|
1860
|
+
van: 'Large or 3-row',
|
|
1861
|
+
minivan: 'Large or 3-row',
|
|
1862
|
+
truck: 'Large or 3-row',
|
|
1863
|
+
};
|
|
1864
|
+
return labels[normalized] ?? raw;
|
|
1865
|
+
}
|
|
1866
|
+
function formatContextValue(value) {
|
|
1867
|
+
if (typeof value === 'string') {
|
|
1868
|
+
const trimmed = value.trim();
|
|
1869
|
+
if (!trimmed)
|
|
1870
|
+
return null;
|
|
1871
|
+
return titleizeContextValue(trimmed);
|
|
1872
|
+
}
|
|
1873
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1874
|
+
return String(value);
|
|
1875
|
+
if (typeof value === 'boolean')
|
|
1876
|
+
return value ? 'Yes' : 'No';
|
|
1877
|
+
if (Array.isArray(value)) {
|
|
1878
|
+
const parts = value.map(formatContextValue).filter(Boolean);
|
|
1879
|
+
return parts.length ? parts.join(', ') : null;
|
|
1880
|
+
}
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
function titleizeContextValue(value) {
|
|
1884
|
+
return value
|
|
1885
|
+
.replace(/[_-]+/g, ' ')
|
|
1886
|
+
.replace(/\s+/g, ' ')
|
|
1887
|
+
.trim()
|
|
1888
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1889
|
+
}
|
|
1890
|
+
function formatContextKey(key) {
|
|
1891
|
+
return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
1892
|
+
}
|
|
1893
|
+
function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
|
|
1894
|
+
const sections = [];
|
|
1895
|
+
if (contextRows.length > 0) {
|
|
1896
|
+
sections.push([
|
|
1897
|
+
`${t('bookingContextTitle')}:`,
|
|
1898
|
+
...contextRows.map((row) => `${row.label}: ${row.value}`),
|
|
1899
|
+
].join('\n'));
|
|
1900
|
+
}
|
|
1901
|
+
const trimmed = additionalNotes.trim();
|
|
1902
|
+
if (trimmed) {
|
|
1903
|
+
sections.push(`${t('notesLabel')}:\n${trimmed}`);
|
|
1904
|
+
}
|
|
1905
|
+
return sections.join('\n\n');
|
|
1906
|
+
}
|
|
1674
1907
|
function readFiniteNumber(value, fallback) {
|
|
1675
1908
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
1676
1909
|
return value;
|
|
@@ -1983,7 +2216,8 @@ function readCentsValue(value) {
|
|
|
1983
2216
|
function initialSelectionMatchesService(initialSelection, service) {
|
|
1984
2217
|
if (!initialSelection)
|
|
1985
2218
|
return false;
|
|
1986
|
-
if (initialSelection.serviceId &&
|
|
2219
|
+
if (initialSelection.serviceId &&
|
|
2220
|
+
initialSelection.serviceId === service._id) {
|
|
1987
2221
|
return true;
|
|
1988
2222
|
}
|
|
1989
2223
|
if (initialSelection.serviceSlug &&
|
|
@@ -2027,6 +2261,70 @@ function readRecord(value) {
|
|
|
2027
2261
|
function readString(value) {
|
|
2028
2262
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
2029
2263
|
}
|
|
2264
|
+
async function prepareBookingPhoto(file) {
|
|
2265
|
+
if (!file.type.startsWith('image/')) {
|
|
2266
|
+
throw new Error('Please choose image files only.');
|
|
2267
|
+
}
|
|
2268
|
+
const dataUrl = await downscaleImageFile(file);
|
|
2269
|
+
const [, base64 = ''] = dataUrl.split(',');
|
|
2270
|
+
if (!base64 || base64.length > MAX_BOOKING_PHOTO_BASE64_LENGTH) {
|
|
2271
|
+
throw new Error('One of the photos is too large. Try a smaller image.');
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
id: `photo_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
|
2275
|
+
filename: jpegFileName(file.name),
|
|
2276
|
+
data: base64,
|
|
2277
|
+
size: Math.ceil((base64.length * 3) / 4),
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
async function downscaleImageFile(file) {
|
|
2281
|
+
const image = await loadImageSource(file);
|
|
2282
|
+
const scale = Math.min(1, MAX_BOOKING_PHOTO_EDGE / Math.max(image.width, image.height));
|
|
2283
|
+
const width = Math.max(1, Math.round(image.width * scale));
|
|
2284
|
+
const height = Math.max(1, Math.round(image.height * scale));
|
|
2285
|
+
const canvas = document.createElement('canvas');
|
|
2286
|
+
canvas.width = width;
|
|
2287
|
+
canvas.height = height;
|
|
2288
|
+
const context = canvas.getContext('2d');
|
|
2289
|
+
if (!context) {
|
|
2290
|
+
throw new Error('Unable to prepare that photo.');
|
|
2291
|
+
}
|
|
2292
|
+
context.drawImage(image.source, 0, 0, width, height);
|
|
2293
|
+
image.close?.();
|
|
2294
|
+
return canvas.toDataURL('image/jpeg', BOOKING_PHOTO_JPEG_QUALITY);
|
|
2295
|
+
}
|
|
2296
|
+
async function loadImageSource(file) {
|
|
2297
|
+
if (typeof createImageBitmap === 'function') {
|
|
2298
|
+
const bitmap = await createImageBitmap(file);
|
|
2299
|
+
return {
|
|
2300
|
+
source: bitmap,
|
|
2301
|
+
width: bitmap.width,
|
|
2302
|
+
height: bitmap.height,
|
|
2303
|
+
close: () => bitmap.close(),
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
const url = URL.createObjectURL(file);
|
|
2307
|
+
try {
|
|
2308
|
+
const image = await new Promise((resolve, reject) => {
|
|
2309
|
+
const element = new Image();
|
|
2310
|
+
element.onload = () => resolve(element);
|
|
2311
|
+
element.onerror = () => reject(new Error('Unable to read that photo.'));
|
|
2312
|
+
element.src = url;
|
|
2313
|
+
});
|
|
2314
|
+
return {
|
|
2315
|
+
source: image,
|
|
2316
|
+
width: image.naturalWidth || image.width,
|
|
2317
|
+
height: image.naturalHeight || image.height,
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
finally {
|
|
2321
|
+
URL.revokeObjectURL(url);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
function jpegFileName(name) {
|
|
2325
|
+
const clean = name.trim() || 'booking-photo.jpg';
|
|
2326
|
+
return `${clean.replace(/\.[^.]+$/, '')}.jpg`;
|
|
2327
|
+
}
|
|
2030
2328
|
// BookingWidgetSkeleton was a hand-built first-load ghost that
|
|
2031
2329
|
// constantly drifted out of sync with the real layout. Boneyard now
|
|
2032
2330
|
// owns the detailed bones; BookingWidgetPlaceholder is the stable
|
|
@@ -2263,11 +2561,35 @@ function zonedDateTimeToTimestamp(dateKey, time, timezone) {
|
|
|
2263
2561
|
}
|
|
2264
2562
|
return guess;
|
|
2265
2563
|
}
|
|
2266
|
-
function
|
|
2564
|
+
function findFirstSelectableFlexibleDate({ calendarMonth, todayDateKey, service, businessHours, timezone, }) {
|
|
2565
|
+
const end = endOfMonth(calendarMonth);
|
|
2566
|
+
const cursor = startOfMonth(calendarMonth);
|
|
2567
|
+
while (cursor <= end) {
|
|
2568
|
+
const dateKey = formatDateKey(cursor);
|
|
2569
|
+
if (dateKey >= todayDateKey &&
|
|
2570
|
+
hasSelectableFlexibleWindowsForDate({
|
|
2571
|
+
dateKey,
|
|
2572
|
+
service,
|
|
2573
|
+
businessHours,
|
|
2574
|
+
timezone,
|
|
2575
|
+
})) {
|
|
2576
|
+
return dateKey;
|
|
2577
|
+
}
|
|
2578
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
2579
|
+
}
|
|
2580
|
+
return null;
|
|
2581
|
+
}
|
|
2582
|
+
function hasSelectableFlexibleWindowsForDate({ dateKey, service, businessHours, timezone, }) {
|
|
2583
|
+
return getFlexibleWindowOptionsForDate({ dateKey, businessHours }).some((window) => isFlexibleWindowSelectable({
|
|
2584
|
+
dateKey,
|
|
2585
|
+
service,
|
|
2586
|
+
timezone,
|
|
2587
|
+
window,
|
|
2588
|
+
}));
|
|
2589
|
+
}
|
|
2590
|
+
function isFlexibleWindowSelectable({ dateKey, service, timezone, window, }) {
|
|
2267
2591
|
if (!service)
|
|
2268
2592
|
return false;
|
|
2269
|
-
if (!isBusinessOpenOnDate(dateKey, businessHours))
|
|
2270
|
-
return false;
|
|
2271
2593
|
const now = Date.now();
|
|
2272
2594
|
const windowEnd = zonedDateTimeToTimestamp(dateKey, window.endTime, timezone);
|
|
2273
2595
|
const latestAllowedStart = now + (service.maxAdvanceBookingDays ?? 90) * 24 * 60 * 60 * 1000;
|
|
@@ -2277,13 +2599,61 @@ function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, w
|
|
|
2277
2599
|
const windowStart = zonedDateTimeToTimestamp(dateKey, window.startTime, timezone);
|
|
2278
2600
|
return windowStart <= latestAllowedStart;
|
|
2279
2601
|
}
|
|
2280
|
-
function
|
|
2281
|
-
|
|
2282
|
-
|
|
2602
|
+
function getFlexibleWindowOptionsForDate({ dateKey, businessHours, }) {
|
|
2603
|
+
const range = getBusinessOpenRangeForDate(dateKey, businessHours);
|
|
2604
|
+
if (!range)
|
|
2605
|
+
return [];
|
|
2606
|
+
const allDay = makeFlexibleWindowOption('all-day', range.start, range.end);
|
|
2607
|
+
const morningEnd = Math.min(range.end, NOON_MINUTES);
|
|
2608
|
+
const afternoonStart = Math.max(range.start, NOON_MINUTES);
|
|
2609
|
+
const options = [allDay];
|
|
2610
|
+
if (morningEnd - range.start >= MIN_FLEXIBLE_WINDOW_MINUTES) {
|
|
2611
|
+
options.push(makeFlexibleWindowOption('morning', range.start, morningEnd));
|
|
2612
|
+
}
|
|
2613
|
+
if (range.end - afternoonStart >= MIN_FLEXIBLE_WINDOW_MINUTES) {
|
|
2614
|
+
options.push(makeFlexibleWindowOption('afternoon', afternoonStart, range.end));
|
|
2615
|
+
}
|
|
2616
|
+
return options;
|
|
2617
|
+
}
|
|
2618
|
+
function makeFlexibleWindowOption(id, startMinutes, endMinutes) {
|
|
2619
|
+
const template = FLEXIBLE_WINDOW_TEMPLATES.find((item) => item.id === id);
|
|
2620
|
+
return {
|
|
2621
|
+
id,
|
|
2622
|
+
startTime: minutesToClockTime(startMinutes),
|
|
2623
|
+
endTime: minutesToClockTime(endMinutes),
|
|
2624
|
+
labelKey: template?.labelKey ?? 'flexWindowAllDay',
|
|
2625
|
+
descriptionKey: template?.descriptionKey ?? 'flexWindowAllDayDesc',
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
function getBusinessOpenRangeForDate(dateKey, businessHours) {
|
|
2629
|
+
if (businessHours.length === 0) {
|
|
2630
|
+
return {
|
|
2631
|
+
start: timeStringToMinutes(DEFAULT_FLEXIBLE_OPEN_TIME),
|
|
2632
|
+
end: timeStringToMinutes(DEFAULT_FLEXIBLE_CLOSE_TIME),
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2283
2635
|
const { year, month, day } = parseDateKeyParts(dateKey);
|
|
2284
2636
|
const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
2285
2637
|
const entry = businessHours.find((hours) => hours.day === dayOfWeek);
|
|
2286
|
-
|
|
2638
|
+
if (!entry?.isOpen)
|
|
2639
|
+
return null;
|
|
2640
|
+
const start = timeStringToMinutes(entry.openTime ?? DEFAULT_FLEXIBLE_OPEN_TIME);
|
|
2641
|
+
const end = timeStringToMinutes(entry.closeTime ?? DEFAULT_FLEXIBLE_CLOSE_TIME);
|
|
2642
|
+
return end > start ? { start, end } : null;
|
|
2643
|
+
}
|
|
2644
|
+
function minutesToClockTime(minutes) {
|
|
2645
|
+
const clamped = Math.max(0, Math.min(23 * 60 + 59, Math.round(minutes)));
|
|
2646
|
+
const hours = Math.floor(clamped / 60);
|
|
2647
|
+
const mins = clamped % 60;
|
|
2648
|
+
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
|
2649
|
+
}
|
|
2650
|
+
function timeStringToMinutes(value) {
|
|
2651
|
+
const [hourString, minuteString] = value.split(':');
|
|
2652
|
+
const hour = Number(hourString);
|
|
2653
|
+
const minute = Number(minuteString);
|
|
2654
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute))
|
|
2655
|
+
return 0;
|
|
2656
|
+
return hour * 60 + minute;
|
|
2287
2657
|
}
|
|
2288
2658
|
function startOfMonth(date) {
|
|
2289
2659
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
@@ -2393,6 +2763,9 @@ function WarningIcon() {
|
|
|
2393
2763
|
function UserIcon() {
|
|
2394
2764
|
return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
|
|
2395
2765
|
}
|
|
2766
|
+
function LocationPinIcon() {
|
|
2767
|
+
return iconPath('M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Zm0-8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z');
|
|
2768
|
+
}
|
|
2396
2769
|
function GlobeIcon() {
|
|
2397
2770
|
return iconPath('M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 0c2.5 2.7 4 6.2 4 10s-1.5 7.3-4 10c-2.5-2.7-4-6.2-4-10s1.5-7.3 4-10ZM2 12h20');
|
|
2398
2771
|
}
|