@asksable/site-connector 0.6.4 → 0.6.5
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 +384 -40
- 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
|
|
@@ -396,12 +438,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
396
438
|
setAvailabilityByDate(new Map());
|
|
397
439
|
setIsAvailabilityLoading(false);
|
|
398
440
|
if (selectedDate &&
|
|
399
|
-
!
|
|
441
|
+
!hasSelectableFlexibleWindowsForDate({
|
|
400
442
|
dateKey: selectedDate,
|
|
401
443
|
service: selectedService,
|
|
402
444
|
businessHours: setup?.businessHours ?? [],
|
|
403
445
|
timezone: bookingTimezone,
|
|
404
|
-
window: selectedFlexibleWindow,
|
|
405
446
|
})) {
|
|
406
447
|
setSelectedDate(null);
|
|
407
448
|
}
|
|
@@ -483,7 +524,6 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
483
524
|
selectedService?.publicBookingMode,
|
|
484
525
|
selectedServiceId,
|
|
485
526
|
selectedStaffId,
|
|
486
|
-
selectedFlexibleWindow,
|
|
487
527
|
selectedService,
|
|
488
528
|
setup?.businessHours,
|
|
489
529
|
siteSlug,
|
|
@@ -756,7 +796,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
756
796
|
(effectiveSchedulingPreference === 'flexible'
|
|
757
797
|
? Boolean(selectedDate && selectedFlexibleWindow)
|
|
758
798
|
: Boolean(selectedDate && selectedSlot && holdId));
|
|
759
|
-
const selectedTimeLabel = selectedDate &&
|
|
799
|
+
const selectedTimeLabel = selectedDate &&
|
|
800
|
+
effectiveSchedulingPreference === 'flexible' &&
|
|
801
|
+
selectedFlexibleWindow
|
|
760
802
|
? formatFlexibleWindowTimeRange(selectedFlexibleWindow, intlLocale)
|
|
761
803
|
: selectedSlot
|
|
762
804
|
? `${formatTimeLabel(selectedSlot.time, intlLocale)} – ${formatTimeLabel(selectedSlot.endTime, intlLocale)}`
|
|
@@ -765,6 +807,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
765
807
|
const canAdvanceStep3 = Boolean(selectedService &&
|
|
766
808
|
customerName.trim() &&
|
|
767
809
|
isCustomerEmailValid &&
|
|
810
|
+
(isReschedule || (trimmedCustomerPhone && trimmedServiceAddress)) &&
|
|
768
811
|
isIntakeComplete);
|
|
769
812
|
// Slots block is rendered in two locations: inline under the calendar
|
|
770
813
|
// (mobile flow keeps current step-3 behavior) and at the top of the
|
|
@@ -793,10 +836,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
793
836
|
})] })) : null;
|
|
794
837
|
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
838
|
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
|
-
|
|
839
|
+
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) => {
|
|
840
|
+
const isActive = selectedFlexibleWindowId === option.id;
|
|
841
|
+
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));
|
|
842
|
+
}), 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
843
|
const slotKey = `${slot.time}-${slot.endTime}`;
|
|
801
844
|
const isPending = pendingSlotKey === slotKey;
|
|
802
845
|
const isActive = isPending ||
|
|
@@ -811,7 +854,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
811
854
|
isIntakeComplete &&
|
|
812
855
|
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
813
856
|
customerName.trim() &&
|
|
814
|
-
isCustomerEmailValid
|
|
857
|
+
isCustomerEmailValid &&
|
|
858
|
+
(isReschedule || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
|
|
815
859
|
const submitBlockers = selectedService
|
|
816
860
|
? getSubmitBlockers({
|
|
817
861
|
selectedService,
|
|
@@ -823,6 +867,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
823
867
|
quote,
|
|
824
868
|
customerName,
|
|
825
869
|
customerEmail,
|
|
870
|
+
customerPhone,
|
|
871
|
+
serviceAddress,
|
|
872
|
+
requireServiceAddress: !isReschedule,
|
|
826
873
|
t,
|
|
827
874
|
locale,
|
|
828
875
|
})
|
|
@@ -830,9 +877,47 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
830
877
|
function handleNotesInput(event) {
|
|
831
878
|
setCustomerNotes(event.currentTarget.value);
|
|
832
879
|
}
|
|
880
|
+
async function handlePhotoInput(event) {
|
|
881
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
882
|
+
event.currentTarget.value = '';
|
|
883
|
+
if (files.length === 0)
|
|
884
|
+
return;
|
|
885
|
+
setPhotoError(null);
|
|
886
|
+
setIsPreparingPhotos(true);
|
|
887
|
+
try {
|
|
888
|
+
const remaining = Math.max(0, MAX_BOOKING_PHOTOS - bookingPhotos.length);
|
|
889
|
+
const accepted = files.slice(0, remaining);
|
|
890
|
+
const prepared = [];
|
|
891
|
+
for (const file of accepted) {
|
|
892
|
+
prepared.push(await prepareBookingPhoto(file));
|
|
893
|
+
}
|
|
894
|
+
setBookingPhotos((current) => [...current, ...prepared].slice(0, MAX_BOOKING_PHOTOS));
|
|
895
|
+
if (files.length > remaining) {
|
|
896
|
+
setPhotoError(t('photosLimit', { count: MAX_BOOKING_PHOTOS }));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
catch (nextError) {
|
|
900
|
+
setPhotoError(nextError instanceof Error ? nextError.message : t('photosFailed'));
|
|
901
|
+
}
|
|
902
|
+
finally {
|
|
903
|
+
setIsPreparingPhotos(false);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
833
906
|
function renderNotesField(id, label, placeholder) {
|
|
834
907
|
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
908
|
}
|
|
909
|
+
function renderBookingContext() {
|
|
910
|
+
if (bookingContextRows.length === 0)
|
|
911
|
+
return null;
|
|
912
|
+
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))) })] }));
|
|
913
|
+
}
|
|
914
|
+
function renderPhotoField(id) {
|
|
915
|
+
if (isReschedule)
|
|
916
|
+
return null;
|
|
917
|
+
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', {
|
|
918
|
+
name: photo.filename,
|
|
919
|
+
}), children: t('photosRemove') })] }, photo.id))) })) : null] }));
|
|
920
|
+
}
|
|
836
921
|
function renderSubmitHelp() {
|
|
837
922
|
if (!selectedService || canSubmit || payment || isSubmitting)
|
|
838
923
|
return null;
|
|
@@ -852,8 +937,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
852
937
|
const nameId = `bw-name${idSuffix}`;
|
|
853
938
|
const emailId = `bw-email${idSuffix}`;
|
|
854
939
|
const phoneId = `bw-phone${idSuffix}`;
|
|
940
|
+
const addressId = `bw-service-address${idSuffix}`;
|
|
855
941
|
const emailErrorId = `${emailId}-error`;
|
|
856
|
-
|
|
942
|
+
const phoneErrorId = `${phoneId}-error`;
|
|
943
|
+
const addressErrorId = `${addressId}-error`;
|
|
944
|
+
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
945
|
}
|
|
858
946
|
function renderIntakeFields(idPrefix) {
|
|
859
947
|
return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
|
|
@@ -1045,16 +1133,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1045
1133
|
: selectedDate && selectedSlot
|
|
1046
1134
|
? zonedDateTimeToTimestamp(selectedDate, selectedSlot.endTime, bookingTimezone)
|
|
1047
1135
|
: newStartTime + selectedService.durationMinutes * 60 * 1000;
|
|
1136
|
+
const bookingBaseNotes = formatBookingNotesForSubmit({
|
|
1137
|
+
contextRows: bookingContextRows,
|
|
1138
|
+
additionalNotes: customerNotes,
|
|
1139
|
+
t,
|
|
1140
|
+
});
|
|
1048
1141
|
const bookingNotes = isFlexibleRequest
|
|
1049
1142
|
? appendFlexibleWindowNote({
|
|
1050
|
-
notes:
|
|
1143
|
+
notes: bookingBaseNotes,
|
|
1051
1144
|
dateKey: selectedDate,
|
|
1052
1145
|
window: selectedFlexibleWindow,
|
|
1053
1146
|
locale: intlLocale,
|
|
1054
1147
|
timezone: bookingTimezone,
|
|
1055
1148
|
t,
|
|
1056
1149
|
})
|
|
1057
|
-
:
|
|
1150
|
+
: bookingBaseNotes || undefined;
|
|
1058
1151
|
// Reschedule mode bypasses the public-create flow entirely.
|
|
1059
1152
|
// Service / staff / customer are already attached to the
|
|
1060
1153
|
// original appointment, so the host (admin path) or magic-link
|
|
@@ -1101,9 +1194,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1101
1194
|
deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
|
|
1102
1195
|
customerName: customerName.trim(),
|
|
1103
1196
|
customerEmail: customerEmail.trim(),
|
|
1104
|
-
customerPhone:
|
|
1197
|
+
customerPhone: trimmedCustomerPhone,
|
|
1105
1198
|
customerNotes: bookingNotes,
|
|
1199
|
+
location: trimmedServiceAddress,
|
|
1106
1200
|
intakeResponses,
|
|
1201
|
+
photos: bookingPhotos.map((photo) => ({
|
|
1202
|
+
filename: photo.filename,
|
|
1203
|
+
data: photo.data,
|
|
1204
|
+
})),
|
|
1107
1205
|
quotedTotalCents: displayQuote?.totalCents,
|
|
1108
1206
|
bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
|
|
1109
1207
|
? holdId
|
|
@@ -1145,7 +1243,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1145
1243
|
setCustomerName('');
|
|
1146
1244
|
setCustomerEmail('');
|
|
1147
1245
|
setCustomerPhone('');
|
|
1246
|
+
setServiceAddress('');
|
|
1148
1247
|
setCustomerNotes('');
|
|
1248
|
+
setBookingPhotos([]);
|
|
1249
|
+
setPhotoError(null);
|
|
1149
1250
|
setIntakeResponses({});
|
|
1150
1251
|
setQuote(null);
|
|
1151
1252
|
setPayment(null);
|
|
@@ -1290,12 +1391,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1290
1391
|
const isExactAvailable = slots.length > 0;
|
|
1291
1392
|
const isFlexibleAvailable = effectiveSchedulingPreference === 'flexible' &&
|
|
1292
1393
|
selectedService != null &&
|
|
1293
|
-
|
|
1394
|
+
hasSelectableFlexibleWindowsForDate({
|
|
1294
1395
|
dateKey,
|
|
1295
1396
|
service: selectedService,
|
|
1296
1397
|
businessHours: setup?.businessHours ?? [],
|
|
1297
1398
|
timezone: bookingTimezone,
|
|
1298
|
-
window: selectedFlexibleWindow,
|
|
1299
1399
|
});
|
|
1300
1400
|
const isAvailable = effectiveSchedulingPreference === 'flexible'
|
|
1301
1401
|
? isFlexibleAvailable
|
|
@@ -1352,18 +1452,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1352
1452
|
}, 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
1453
|
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
1454
|
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') })] })] })] }), (() => {
|
|
1455
|
+
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
1456
|
const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
|
|
1357
1457
|
if (intakeRows.length === 0)
|
|
1358
1458
|
return null;
|
|
1359
1459
|
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
|
|
1460
|
+
})(), 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
1461
|
? 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
|
|
1462
|
+
: 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
1463
|
? t('summaryCalculating')
|
|
1364
1464
|
: 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
1465
|
? t('flexWindowHeading')
|
|
1366
|
-
: t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children:
|
|
1466
|
+
: 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
1467
|
? t('summaryTitleReschedule')
|
|
1368
1468
|
: 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
1469
|
...summaryValStyle,
|
|
@@ -1398,11 +1498,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1398
1498
|
? t('summaryNewTime')
|
|
1399
1499
|
: 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
1500
|
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
|
|
1501
|
+
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
1502
|
? 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
|
|
1503
|
+
: 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
1504
|
? t('notesPlaceholderReschedule')
|
|
1405
|
-
: t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
|
|
1505
|
+
: t('notesPlaceholder')), renderPhotoField('bw-photos-d'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
|
|
1406
1506
|
(forcedState === 'payment-full' ||
|
|
1407
1507
|
forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
|
|
1408
1508
|
setSuccess(t('successPaymentReceived'));
|
|
@@ -1512,7 +1612,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
|
1512
1612
|
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1513
1613
|
}));
|
|
1514
1614
|
}
|
|
1515
|
-
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
|
|
1615
|
+
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
|
|
1516
1616
|
const blockers = [];
|
|
1517
1617
|
if (selectedServiceRequiresSlot && !hasScheduleSelection) {
|
|
1518
1618
|
blockers.push(t('blockerDateTime'));
|
|
@@ -1525,6 +1625,11 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
|
|
|
1525
1625
|
else if (!isValidEmailAddress(customerEmail)) {
|
|
1526
1626
|
blockers.push(t('blockerContactEmailInvalid'));
|
|
1527
1627
|
}
|
|
1628
|
+
if (!customerPhone.trim())
|
|
1629
|
+
blockers.push(t('blockerContactPhone'));
|
|
1630
|
+
if (requireServiceAddress && !serviceAddress.trim()) {
|
|
1631
|
+
blockers.push(t('blockerServiceAddress'));
|
|
1632
|
+
}
|
|
1528
1633
|
if (!isIntakeComplete && selectedService.intakeForm) {
|
|
1529
1634
|
blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
|
|
1530
1635
|
}
|
|
@@ -1671,6 +1776,123 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
|
|
|
1671
1776
|
}
|
|
1672
1777
|
return rows;
|
|
1673
1778
|
}
|
|
1779
|
+
function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
1780
|
+
const rows = [];
|
|
1781
|
+
const usedKeys = new Set();
|
|
1782
|
+
const addKnown = (keys, label, formatter = formatContextValue) => {
|
|
1783
|
+
for (const key of keys) {
|
|
1784
|
+
const value = intakeResponses[key];
|
|
1785
|
+
const formatted = formatter(value);
|
|
1786
|
+
if (formatted) {
|
|
1787
|
+
rows.push({ key, label, value: formatted });
|
|
1788
|
+
usedKeys.add(key);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
addKnown(['vehicle_size', 'vehicleSize', 'vehicle', 'vehicle_type', 'vehicleType'], 'Vehicle', formatVehicleContextValue);
|
|
1794
|
+
addKnown(['pet_hair', 'petHair', 'pet_hair_level'], 'Pet hair');
|
|
1795
|
+
addKnown([
|
|
1796
|
+
'stains',
|
|
1797
|
+
'stain_removal',
|
|
1798
|
+
'stainRemoval',
|
|
1799
|
+
'shampooing',
|
|
1800
|
+
'shampoo_needed',
|
|
1801
|
+
'shampooNeeded',
|
|
1802
|
+
'stains_or_shampooing',
|
|
1803
|
+
], 'Stains or shampooing');
|
|
1804
|
+
const estimateCents = readCentsValue(intakeResponses.estimate_cents) ??
|
|
1805
|
+
readCentsValue(intakeResponses.estimateCents) ??
|
|
1806
|
+
quote?.totalCents ??
|
|
1807
|
+
null;
|
|
1808
|
+
if (estimateCents !== null) {
|
|
1809
|
+
rows.push({
|
|
1810
|
+
key: 'estimate_cents',
|
|
1811
|
+
label: 'Website estimate',
|
|
1812
|
+
value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
|
|
1813
|
+
});
|
|
1814
|
+
usedKeys.add('estimate_cents');
|
|
1815
|
+
usedKeys.add('estimateCents');
|
|
1816
|
+
}
|
|
1817
|
+
for (const [key, value] of Object.entries(intakeResponses)) {
|
|
1818
|
+
if (usedKeys.has(key))
|
|
1819
|
+
continue;
|
|
1820
|
+
if (key === 'estimate_label' || key === 'estimateLabel')
|
|
1821
|
+
continue;
|
|
1822
|
+
const formatted = formatContextValue(value);
|
|
1823
|
+
if (!formatted)
|
|
1824
|
+
continue;
|
|
1825
|
+
rows.push({
|
|
1826
|
+
key,
|
|
1827
|
+
label: formatContextKey(key),
|
|
1828
|
+
value: formatted,
|
|
1829
|
+
});
|
|
1830
|
+
if (rows.length >= 8)
|
|
1831
|
+
break;
|
|
1832
|
+
}
|
|
1833
|
+
return rows;
|
|
1834
|
+
}
|
|
1835
|
+
function formatVehicleContextValue(value) {
|
|
1836
|
+
const raw = formatContextValue(value);
|
|
1837
|
+
if (!raw)
|
|
1838
|
+
return null;
|
|
1839
|
+
const normalized = raw.toLowerCase().replace(/[\s_-]+/g, '-');
|
|
1840
|
+
const labels = {
|
|
1841
|
+
car: 'Car or small SUV',
|
|
1842
|
+
small: 'Car or small SUV',
|
|
1843
|
+
sedan: 'Car or small SUV',
|
|
1844
|
+
'small-suv': 'Car or small SUV',
|
|
1845
|
+
large: 'Large or 3-row',
|
|
1846
|
+
'3-row': 'Large or 3-row',
|
|
1847
|
+
'three-row': 'Large or 3-row',
|
|
1848
|
+
suv: 'Large or 3-row',
|
|
1849
|
+
van: 'Large or 3-row',
|
|
1850
|
+
minivan: 'Large or 3-row',
|
|
1851
|
+
truck: 'Large or 3-row',
|
|
1852
|
+
};
|
|
1853
|
+
return labels[normalized] ?? raw;
|
|
1854
|
+
}
|
|
1855
|
+
function formatContextValue(value) {
|
|
1856
|
+
if (typeof value === 'string') {
|
|
1857
|
+
const trimmed = value.trim();
|
|
1858
|
+
if (!trimmed)
|
|
1859
|
+
return null;
|
|
1860
|
+
return titleizeContextValue(trimmed);
|
|
1861
|
+
}
|
|
1862
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1863
|
+
return String(value);
|
|
1864
|
+
if (typeof value === 'boolean')
|
|
1865
|
+
return value ? 'Yes' : 'No';
|
|
1866
|
+
if (Array.isArray(value)) {
|
|
1867
|
+
const parts = value.map(formatContextValue).filter(Boolean);
|
|
1868
|
+
return parts.length ? parts.join(', ') : null;
|
|
1869
|
+
}
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
function titleizeContextValue(value) {
|
|
1873
|
+
return value
|
|
1874
|
+
.replace(/[_-]+/g, ' ')
|
|
1875
|
+
.replace(/\s+/g, ' ')
|
|
1876
|
+
.trim()
|
|
1877
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1878
|
+
}
|
|
1879
|
+
function formatContextKey(key) {
|
|
1880
|
+
return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
1881
|
+
}
|
|
1882
|
+
function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
|
|
1883
|
+
const sections = [];
|
|
1884
|
+
if (contextRows.length > 0) {
|
|
1885
|
+
sections.push([
|
|
1886
|
+
`${t('bookingContextTitle')}:`,
|
|
1887
|
+
...contextRows.map((row) => `${row.label}: ${row.value}`),
|
|
1888
|
+
].join('\n'));
|
|
1889
|
+
}
|
|
1890
|
+
const trimmed = additionalNotes.trim();
|
|
1891
|
+
if (trimmed) {
|
|
1892
|
+
sections.push(`${t('notesLabel')}:\n${trimmed}`);
|
|
1893
|
+
}
|
|
1894
|
+
return sections.join('\n\n');
|
|
1895
|
+
}
|
|
1674
1896
|
function readFiniteNumber(value, fallback) {
|
|
1675
1897
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
1676
1898
|
return value;
|
|
@@ -1983,7 +2205,8 @@ function readCentsValue(value) {
|
|
|
1983
2205
|
function initialSelectionMatchesService(initialSelection, service) {
|
|
1984
2206
|
if (!initialSelection)
|
|
1985
2207
|
return false;
|
|
1986
|
-
if (initialSelection.serviceId &&
|
|
2208
|
+
if (initialSelection.serviceId &&
|
|
2209
|
+
initialSelection.serviceId === service._id) {
|
|
1987
2210
|
return true;
|
|
1988
2211
|
}
|
|
1989
2212
|
if (initialSelection.serviceSlug &&
|
|
@@ -2027,6 +2250,70 @@ function readRecord(value) {
|
|
|
2027
2250
|
function readString(value) {
|
|
2028
2251
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
2029
2252
|
}
|
|
2253
|
+
async function prepareBookingPhoto(file) {
|
|
2254
|
+
if (!file.type.startsWith('image/')) {
|
|
2255
|
+
throw new Error('Please choose image files only.');
|
|
2256
|
+
}
|
|
2257
|
+
const dataUrl = await downscaleImageFile(file);
|
|
2258
|
+
const [, base64 = ''] = dataUrl.split(',');
|
|
2259
|
+
if (!base64 || base64.length > MAX_BOOKING_PHOTO_BASE64_LENGTH) {
|
|
2260
|
+
throw new Error('One of the photos is too large. Try a smaller image.');
|
|
2261
|
+
}
|
|
2262
|
+
return {
|
|
2263
|
+
id: `photo_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
|
2264
|
+
filename: jpegFileName(file.name),
|
|
2265
|
+
data: base64,
|
|
2266
|
+
size: Math.ceil((base64.length * 3) / 4),
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
async function downscaleImageFile(file) {
|
|
2270
|
+
const image = await loadImageSource(file);
|
|
2271
|
+
const scale = Math.min(1, MAX_BOOKING_PHOTO_EDGE / Math.max(image.width, image.height));
|
|
2272
|
+
const width = Math.max(1, Math.round(image.width * scale));
|
|
2273
|
+
const height = Math.max(1, Math.round(image.height * scale));
|
|
2274
|
+
const canvas = document.createElement('canvas');
|
|
2275
|
+
canvas.width = width;
|
|
2276
|
+
canvas.height = height;
|
|
2277
|
+
const context = canvas.getContext('2d');
|
|
2278
|
+
if (!context) {
|
|
2279
|
+
throw new Error('Unable to prepare that photo.');
|
|
2280
|
+
}
|
|
2281
|
+
context.drawImage(image.source, 0, 0, width, height);
|
|
2282
|
+
image.close?.();
|
|
2283
|
+
return canvas.toDataURL('image/jpeg', BOOKING_PHOTO_JPEG_QUALITY);
|
|
2284
|
+
}
|
|
2285
|
+
async function loadImageSource(file) {
|
|
2286
|
+
if (typeof createImageBitmap === 'function') {
|
|
2287
|
+
const bitmap = await createImageBitmap(file);
|
|
2288
|
+
return {
|
|
2289
|
+
source: bitmap,
|
|
2290
|
+
width: bitmap.width,
|
|
2291
|
+
height: bitmap.height,
|
|
2292
|
+
close: () => bitmap.close(),
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
const url = URL.createObjectURL(file);
|
|
2296
|
+
try {
|
|
2297
|
+
const image = await new Promise((resolve, reject) => {
|
|
2298
|
+
const element = new Image();
|
|
2299
|
+
element.onload = () => resolve(element);
|
|
2300
|
+
element.onerror = () => reject(new Error('Unable to read that photo.'));
|
|
2301
|
+
element.src = url;
|
|
2302
|
+
});
|
|
2303
|
+
return {
|
|
2304
|
+
source: image,
|
|
2305
|
+
width: image.naturalWidth || image.width,
|
|
2306
|
+
height: image.naturalHeight || image.height,
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
finally {
|
|
2310
|
+
URL.revokeObjectURL(url);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
function jpegFileName(name) {
|
|
2314
|
+
const clean = name.trim() || 'booking-photo.jpg';
|
|
2315
|
+
return `${clean.replace(/\.[^.]+$/, '')}.jpg`;
|
|
2316
|
+
}
|
|
2030
2317
|
// BookingWidgetSkeleton was a hand-built first-load ghost that
|
|
2031
2318
|
// constantly drifted out of sync with the real layout. Boneyard now
|
|
2032
2319
|
// owns the detailed bones; BookingWidgetPlaceholder is the stable
|
|
@@ -2263,11 +2550,17 @@ function zonedDateTimeToTimestamp(dateKey, time, timezone) {
|
|
|
2263
2550
|
}
|
|
2264
2551
|
return guess;
|
|
2265
2552
|
}
|
|
2266
|
-
function
|
|
2553
|
+
function hasSelectableFlexibleWindowsForDate({ dateKey, service, businessHours, timezone, }) {
|
|
2554
|
+
return getFlexibleWindowOptionsForDate({ dateKey, businessHours }).some((window) => isFlexibleWindowSelectable({
|
|
2555
|
+
dateKey,
|
|
2556
|
+
service,
|
|
2557
|
+
timezone,
|
|
2558
|
+
window,
|
|
2559
|
+
}));
|
|
2560
|
+
}
|
|
2561
|
+
function isFlexibleWindowSelectable({ dateKey, service, timezone, window, }) {
|
|
2267
2562
|
if (!service)
|
|
2268
2563
|
return false;
|
|
2269
|
-
if (!isBusinessOpenOnDate(dateKey, businessHours))
|
|
2270
|
-
return false;
|
|
2271
2564
|
const now = Date.now();
|
|
2272
2565
|
const windowEnd = zonedDateTimeToTimestamp(dateKey, window.endTime, timezone);
|
|
2273
2566
|
const latestAllowedStart = now + (service.maxAdvanceBookingDays ?? 90) * 24 * 60 * 60 * 1000;
|
|
@@ -2277,13 +2570,61 @@ function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, w
|
|
|
2277
2570
|
const windowStart = zonedDateTimeToTimestamp(dateKey, window.startTime, timezone);
|
|
2278
2571
|
return windowStart <= latestAllowedStart;
|
|
2279
2572
|
}
|
|
2280
|
-
function
|
|
2281
|
-
|
|
2282
|
-
|
|
2573
|
+
function getFlexibleWindowOptionsForDate({ dateKey, businessHours, }) {
|
|
2574
|
+
const range = getBusinessOpenRangeForDate(dateKey, businessHours);
|
|
2575
|
+
if (!range)
|
|
2576
|
+
return [];
|
|
2577
|
+
const allDay = makeFlexibleWindowOption('all-day', range.start, range.end);
|
|
2578
|
+
const morningEnd = Math.min(range.end, NOON_MINUTES);
|
|
2579
|
+
const afternoonStart = Math.max(range.start, NOON_MINUTES);
|
|
2580
|
+
const options = [allDay];
|
|
2581
|
+
if (morningEnd - range.start >= MIN_FLEXIBLE_WINDOW_MINUTES) {
|
|
2582
|
+
options.push(makeFlexibleWindowOption('morning', range.start, morningEnd));
|
|
2583
|
+
}
|
|
2584
|
+
if (range.end - afternoonStart >= MIN_FLEXIBLE_WINDOW_MINUTES) {
|
|
2585
|
+
options.push(makeFlexibleWindowOption('afternoon', afternoonStart, range.end));
|
|
2586
|
+
}
|
|
2587
|
+
return options;
|
|
2588
|
+
}
|
|
2589
|
+
function makeFlexibleWindowOption(id, startMinutes, endMinutes) {
|
|
2590
|
+
const template = FLEXIBLE_WINDOW_TEMPLATES.find((item) => item.id === id);
|
|
2591
|
+
return {
|
|
2592
|
+
id,
|
|
2593
|
+
startTime: minutesToClockTime(startMinutes),
|
|
2594
|
+
endTime: minutesToClockTime(endMinutes),
|
|
2595
|
+
labelKey: template?.labelKey ?? 'flexWindowAllDay',
|
|
2596
|
+
descriptionKey: template?.descriptionKey ?? 'flexWindowAllDayDesc',
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
function getBusinessOpenRangeForDate(dateKey, businessHours) {
|
|
2600
|
+
if (businessHours.length === 0) {
|
|
2601
|
+
return {
|
|
2602
|
+
start: timeStringToMinutes(DEFAULT_FLEXIBLE_OPEN_TIME),
|
|
2603
|
+
end: timeStringToMinutes(DEFAULT_FLEXIBLE_CLOSE_TIME),
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2283
2606
|
const { year, month, day } = parseDateKeyParts(dateKey);
|
|
2284
2607
|
const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
2285
2608
|
const entry = businessHours.find((hours) => hours.day === dayOfWeek);
|
|
2286
|
-
|
|
2609
|
+
if (!entry?.isOpen)
|
|
2610
|
+
return null;
|
|
2611
|
+
const start = timeStringToMinutes(entry.openTime ?? DEFAULT_FLEXIBLE_OPEN_TIME);
|
|
2612
|
+
const end = timeStringToMinutes(entry.closeTime ?? DEFAULT_FLEXIBLE_CLOSE_TIME);
|
|
2613
|
+
return end > start ? { start, end } : null;
|
|
2614
|
+
}
|
|
2615
|
+
function minutesToClockTime(minutes) {
|
|
2616
|
+
const clamped = Math.max(0, Math.min(23 * 60 + 59, Math.round(minutes)));
|
|
2617
|
+
const hours = Math.floor(clamped / 60);
|
|
2618
|
+
const mins = clamped % 60;
|
|
2619
|
+
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
|
2620
|
+
}
|
|
2621
|
+
function timeStringToMinutes(value) {
|
|
2622
|
+
const [hourString, minuteString] = value.split(':');
|
|
2623
|
+
const hour = Number(hourString);
|
|
2624
|
+
const minute = Number(minuteString);
|
|
2625
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute))
|
|
2626
|
+
return 0;
|
|
2627
|
+
return hour * 60 + minute;
|
|
2287
2628
|
}
|
|
2288
2629
|
function startOfMonth(date) {
|
|
2289
2630
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
@@ -2393,6 +2734,9 @@ function WarningIcon() {
|
|
|
2393
2734
|
function UserIcon() {
|
|
2394
2735
|
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
2736
|
}
|
|
2737
|
+
function LocationPinIcon() {
|
|
2738
|
+
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');
|
|
2739
|
+
}
|
|
2396
2740
|
function GlobeIcon() {
|
|
2397
2741
|
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
2742
|
}
|