@asksable/site-connector 0.6.5 → 0.6.7
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 +272 -36
- package/dist/booking-widget.js.map +1 -1
- package/dist/styles.css +103 -0
- package/dist/translations.d.ts +6 -0
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +6 -0
- package/dist/translations.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-widget.d.ts","sourceRoot":"","sources":["../src/booking-widget.tsx"],"names":[],"mappings":"AAaA,OAAO,qBAAqB,CAAA;AAc5B,OAAO,KAAK,EAKV,SAAS,EACV,MAAM,OAAO,CAAA;AAcd,KAAK,oBAAoB,GAAG,OAAO,GAAG,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"booking-widget.d.ts","sourceRoot":"","sources":["../src/booking-widget.tsx"],"names":[],"mappings":"AAaA,OAAO,qBAAqB,CAAA;AAc5B,OAAO,KAAK,EAKV,SAAS,EACV,MAAM,OAAO,CAAA;AAcd,KAAK,oBAAoB,GAAG,OAAO,GAAG,UAAU,CAAA;AA6MhD;;;;;GAKG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB;;qBAEiB;IACjB,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB;qCACiC;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB;;sBAEkB;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;yCACqC;IACrC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;oEACgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACzC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,sEAAsE;IACtE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,CAAA;AAED,KAAK,kBAAkB,GAAG;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,SAAS,CAAA;IACxB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,YAAY,CAAA;IAC9B,iBAAiB,CAAC,EAAE,wBAAwB,CAAA;IAC5C;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE;QAC1B,YAAY,EAAE,MAAM,CAAA;QACpB,UAAU,EAAE,MAAM,CAAA;KACnB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACnB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,uBAAuB,CAAA;IAC1C;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAA;IACjC;;;OAGG;IACH,2BAA2B,CAAC,EAAE,oBAAoB,CAAA;IAClD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,cAAc,GAAG,iBAAiB,CAAA;IAC9E,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B,CAAA;AAKD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,WAAW,EACX,YAAY,EACZ,IAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,uBAA+B,EAC/B,2BAAqC,EACrC,mBAAyB,EACzB,eAAe,EACf,iBAAiB,GAClB,EAAE,kBAAkB,2CAgzGpB"}
|
package/dist/booking-widget.js
CHANGED
|
@@ -37,6 +37,7 @@ const MAX_BOOKING_PHOTO_BASE64_LENGTH = 1_500_000;
|
|
|
37
37
|
const MAX_BOOKING_PHOTO_EDGE = 1600;
|
|
38
38
|
const BOOKING_PHOTO_JPEG_QUALITY = 0.78;
|
|
39
39
|
const stripePromises = new Map();
|
|
40
|
+
const googlePlacesPromises = new Map();
|
|
40
41
|
function getStripePromise(publishableKey, connectAccountId) {
|
|
41
42
|
const key = `${publishableKey}:${connectAccountId}`;
|
|
42
43
|
const cached = stripePromises.get(key);
|
|
@@ -48,6 +49,53 @@ function getStripePromise(publishableKey, connectAccountId) {
|
|
|
48
49
|
stripePromises.set(key, promise);
|
|
49
50
|
return promise;
|
|
50
51
|
}
|
|
52
|
+
function getLoadedGooglePlacesApi() {
|
|
53
|
+
if (typeof window === 'undefined')
|
|
54
|
+
return null;
|
|
55
|
+
return (window.google?.maps?.places ?? null);
|
|
56
|
+
}
|
|
57
|
+
function loadGooglePlacesApi(apiKey) {
|
|
58
|
+
const key = apiKey.trim();
|
|
59
|
+
if (!key) {
|
|
60
|
+
return Promise.reject(new Error('Missing Google Maps browser key.'));
|
|
61
|
+
}
|
|
62
|
+
const loaded = getLoadedGooglePlacesApi();
|
|
63
|
+
if (loaded)
|
|
64
|
+
return Promise.resolve(loaded);
|
|
65
|
+
const cached = googlePlacesPromises.get(key);
|
|
66
|
+
if (cached)
|
|
67
|
+
return cached;
|
|
68
|
+
const promise = new Promise((resolve, reject) => {
|
|
69
|
+
if (typeof document === 'undefined') {
|
|
70
|
+
reject(new Error('Google Maps can only load in the browser.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const script = document.createElement('script');
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
key,
|
|
76
|
+
libraries: 'places',
|
|
77
|
+
v: 'weekly',
|
|
78
|
+
});
|
|
79
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
|
|
80
|
+
script.async = true;
|
|
81
|
+
script.defer = true;
|
|
82
|
+
script.onload = () => {
|
|
83
|
+
const next = getLoadedGooglePlacesApi();
|
|
84
|
+
if (next) {
|
|
85
|
+
resolve(next);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
reject(new Error('Google Places failed to initialize.'));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
script.onerror = () => {
|
|
92
|
+
reject(new Error('Google Places failed to load.'));
|
|
93
|
+
};
|
|
94
|
+
document.head.appendChild(script);
|
|
95
|
+
});
|
|
96
|
+
googlePlacesPromises.set(key, promise);
|
|
97
|
+
return promise;
|
|
98
|
+
}
|
|
51
99
|
function isDevRuntime() {
|
|
52
100
|
const env = import.meta.env;
|
|
53
101
|
return env?.DEV === true || env?.MODE === 'development';
|
|
@@ -116,6 +164,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
116
164
|
const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
|
|
117
165
|
const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
|
|
118
166
|
const [serviceAddress, setServiceAddress] = useState('');
|
|
167
|
+
const [isServiceAddressValid, setIsServiceAddressValid] = useState(true);
|
|
119
168
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
120
169
|
const [bookingPhotos, setBookingPhotos] = useState([]);
|
|
121
170
|
const [isPreparingPhotos, setIsPreparingPhotos] = useState(false);
|
|
@@ -335,7 +384,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
335
384
|
const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
|
|
336
385
|
const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
|
|
337
386
|
const showCustomerPhoneError = submitAttempted && !trimmedCustomerPhone;
|
|
338
|
-
const
|
|
387
|
+
const showServiceAddressRequiredError = submitAttempted && !isReschedule && !trimmedServiceAddress;
|
|
388
|
+
const showServiceAddressSelectionError = submitAttempted &&
|
|
389
|
+
!isReschedule &&
|
|
390
|
+
trimmedServiceAddress.length > 0 &&
|
|
391
|
+
!isServiceAddressValid;
|
|
392
|
+
const showServiceAddressError = showServiceAddressRequiredError || showServiceAddressSelectionError;
|
|
339
393
|
// In reschedule mode we pre-select the original service but keep
|
|
340
394
|
// the full list visible - the admin may legitimately need to
|
|
341
395
|
// switch service when rescheduling (e.g. customer asked for a
|
|
@@ -437,14 +491,24 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
437
491
|
if (effectiveSchedulingPreference === 'flexible') {
|
|
438
492
|
setAvailabilityByDate(new Map());
|
|
439
493
|
setIsAvailabilityLoading(false);
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
494
|
+
const firstAvailableDate = findFirstSelectableFlexibleDate({
|
|
495
|
+
calendarMonth,
|
|
496
|
+
todayDateKey,
|
|
497
|
+
service: selectedService,
|
|
498
|
+
businessHours: setup?.businessHours ?? [],
|
|
499
|
+
timezone: bookingTimezone,
|
|
500
|
+
});
|
|
501
|
+
if (!selectedDate) {
|
|
502
|
+
setSelectedDate(firstAvailableDate);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!hasSelectableFlexibleWindowsForDate({
|
|
506
|
+
dateKey: selectedDate,
|
|
507
|
+
service: selectedService,
|
|
508
|
+
businessHours: setup?.businessHours ?? [],
|
|
509
|
+
timezone: bookingTimezone,
|
|
510
|
+
})) {
|
|
511
|
+
setSelectedDate(firstAvailableDate);
|
|
448
512
|
}
|
|
449
513
|
return;
|
|
450
514
|
}
|
|
@@ -527,6 +591,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
527
591
|
selectedService,
|
|
528
592
|
setup?.businessHours,
|
|
529
593
|
siteSlug,
|
|
594
|
+
todayDateKey,
|
|
530
595
|
]);
|
|
531
596
|
useEffect(() => {
|
|
532
597
|
if (!selectedServiceId ||
|
|
@@ -807,7 +872,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
807
872
|
const canAdvanceStep3 = Boolean(selectedService &&
|
|
808
873
|
customerName.trim() &&
|
|
809
874
|
isCustomerEmailValid &&
|
|
810
|
-
(isReschedule ||
|
|
875
|
+
(isReschedule ||
|
|
876
|
+
(trimmedCustomerPhone &&
|
|
877
|
+
trimmedServiceAddress &&
|
|
878
|
+
isServiceAddressValid)) &&
|
|
811
879
|
isIntakeComplete);
|
|
812
880
|
// Slots block is rendered in two locations: inline under the calendar
|
|
813
881
|
// (mobile flow keeps current step-3 behavior) and at the top of the
|
|
@@ -855,7 +923,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
855
923
|
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
856
924
|
customerName.trim() &&
|
|
857
925
|
isCustomerEmailValid &&
|
|
858
|
-
(isReschedule ||
|
|
926
|
+
(isReschedule ||
|
|
927
|
+
(trimmedCustomerPhone &&
|
|
928
|
+
trimmedServiceAddress &&
|
|
929
|
+
isServiceAddressValid))) && !isSubmitting;
|
|
859
930
|
const submitBlockers = selectedService
|
|
860
931
|
? getSubmitBlockers({
|
|
861
932
|
selectedService,
|
|
@@ -869,6 +940,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
869
940
|
customerEmail,
|
|
870
941
|
customerPhone,
|
|
871
942
|
serviceAddress,
|
|
943
|
+
serviceAddressIsValid: isServiceAddressValid,
|
|
872
944
|
requireServiceAddress: !isReschedule,
|
|
873
945
|
t,
|
|
874
946
|
locale,
|
|
@@ -941,7 +1013,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
941
1013
|
const emailErrorId = `${emailId}-error`;
|
|
942
1014
|
const phoneErrorId = `${phoneId}-error`;
|
|
943
1015
|
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(
|
|
1016
|
+
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(BookingAddressAutocompleteInput, { id: addressId, value: serviceAddress, onChange: setServiceAddress, onValidityChange: setIsServiceAddressValid, mapsApiKey: setup?.mapsBrowserKey, "aria-invalid": showServiceAddressError, "aria-describedby": showServiceAddressRequiredError ? addressErrorId : undefined, showInvalid: showServiceAddressSelectionError, invalidMessage: t('contactServiceAddressInvalid'), lookupUnavailableMessage: t('contactServiceAddressLookupUnavailable'), placeholder: t('placeholderServiceAddress') }), showServiceAddressRequiredError ? (_jsx("span", { className: "bw-field-error", id: addressErrorId, children: t('contactServiceAddressRequired') })) : null] })) : null] }));
|
|
945
1017
|
}
|
|
946
1018
|
function renderIntakeFields(idPrefix) {
|
|
947
1019
|
return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
|
|
@@ -1612,7 +1684,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
|
1612
1684
|
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1613
1685
|
}));
|
|
1614
1686
|
}
|
|
1615
|
-
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
|
|
1687
|
+
function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, serviceAddressIsValid, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
|
|
1616
1688
|
const blockers = [];
|
|
1617
1689
|
if (selectedServiceRequiresSlot && !hasScheduleSelection) {
|
|
1618
1690
|
blockers.push(t('blockerDateTime'));
|
|
@@ -1630,6 +1702,9 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
|
|
|
1630
1702
|
if (requireServiceAddress && !serviceAddress.trim()) {
|
|
1631
1703
|
blockers.push(t('blockerServiceAddress'));
|
|
1632
1704
|
}
|
|
1705
|
+
else if (requireServiceAddress && !serviceAddressIsValid) {
|
|
1706
|
+
blockers.push(t('blockerServiceAddressInvalid'));
|
|
1707
|
+
}
|
|
1633
1708
|
if (!isIntakeComplete && selectedService.intakeForm) {
|
|
1634
1709
|
blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
|
|
1635
1710
|
}
|
|
@@ -1778,14 +1853,12 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
|
|
|
1778
1853
|
}
|
|
1779
1854
|
function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
1780
1855
|
const rows = [];
|
|
1781
|
-
const usedKeys = new Set();
|
|
1782
1856
|
const addKnown = (keys, label, formatter = formatContextValue) => {
|
|
1783
1857
|
for (const key of keys) {
|
|
1784
1858
|
const value = intakeResponses[key];
|
|
1785
1859
|
const formatted = formatter(value);
|
|
1786
1860
|
if (formatted) {
|
|
1787
1861
|
rows.push({ key, label, value: formatted });
|
|
1788
|
-
usedKeys.add(key);
|
|
1789
1862
|
return;
|
|
1790
1863
|
}
|
|
1791
1864
|
}
|
|
@@ -1811,24 +1884,6 @@ function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
|
1811
1884
|
label: 'Website estimate',
|
|
1812
1885
|
value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
|
|
1813
1886
|
});
|
|
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
1887
|
}
|
|
1833
1888
|
return rows;
|
|
1834
1889
|
}
|
|
@@ -1876,9 +1931,6 @@ function titleizeContextValue(value) {
|
|
|
1876
1931
|
.trim()
|
|
1877
1932
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1878
1933
|
}
|
|
1879
|
-
function formatContextKey(key) {
|
|
1880
|
-
return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
1881
|
-
}
|
|
1882
1934
|
function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
|
|
1883
1935
|
const sections = [];
|
|
1884
1936
|
if (contextRows.length > 0) {
|
|
@@ -2109,6 +2161,172 @@ function IntakeField({ field, value, onChange, idPrefix, }) {
|
|
|
2109
2161
|
: event.target.value);
|
|
2110
2162
|
} }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
|
|
2111
2163
|
}
|
|
2164
|
+
const ADDRESS_PREDICTION_DEBOUNCE_MS = 180;
|
|
2165
|
+
function BookingAddressAutocompleteInput({ id, value, onChange, onValidityChange, mapsApiKey, placeholder, ariaInvalid, ariaDescribedBy, showInvalid, invalidMessage, lookupUnavailableMessage, }) {
|
|
2166
|
+
const inputRef = useRef(null);
|
|
2167
|
+
const debounceRef = useRef(null);
|
|
2168
|
+
const serviceRef = useRef(null);
|
|
2169
|
+
const placesServiceRef = useRef(null);
|
|
2170
|
+
const sessionTokenRef = useRef(null);
|
|
2171
|
+
const focusedRef = useRef(false);
|
|
2172
|
+
const [acceptedValue, setAcceptedValue] = useState('');
|
|
2173
|
+
const [predictions, setPredictions] = useState([]);
|
|
2174
|
+
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
2175
|
+
const [open, setOpen] = useState(false);
|
|
2176
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
2177
|
+
const [placesReady, setPlacesReady] = useState(false);
|
|
2178
|
+
const apiKey = mapsApiKey?.trim() || null;
|
|
2179
|
+
const mapsConfigured = Boolean(apiKey);
|
|
2180
|
+
const needsGoogleSelection = mapsConfigured;
|
|
2181
|
+
const trimmedValue = value.trim();
|
|
2182
|
+
const isGoogleValid = !needsGoogleSelection ||
|
|
2183
|
+
trimmedValue.length === 0 ||
|
|
2184
|
+
acceptedValue === trimmedValue;
|
|
2185
|
+
useEffect(() => {
|
|
2186
|
+
onValidityChange(isGoogleValid);
|
|
2187
|
+
}, [isGoogleValid, onValidityChange]);
|
|
2188
|
+
useEffect(() => () => {
|
|
2189
|
+
if (debounceRef.current)
|
|
2190
|
+
clearTimeout(debounceRef.current);
|
|
2191
|
+
}, []);
|
|
2192
|
+
useEffect(() => {
|
|
2193
|
+
if (!apiKey) {
|
|
2194
|
+
setPlacesReady(false);
|
|
2195
|
+
setLoadFailed(false);
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
let cancelled = false;
|
|
2199
|
+
void loadGooglePlacesApi(apiKey)
|
|
2200
|
+
.then((places) => {
|
|
2201
|
+
if (cancelled)
|
|
2202
|
+
return;
|
|
2203
|
+
serviceRef.current = new places.AutocompleteService();
|
|
2204
|
+
placesServiceRef.current = new places.PlacesService(document.createElement('div'));
|
|
2205
|
+
sessionTokenRef.current = new places.AutocompleteSessionToken();
|
|
2206
|
+
setLoadFailed(false);
|
|
2207
|
+
setPlacesReady(true);
|
|
2208
|
+
})
|
|
2209
|
+
.catch(() => {
|
|
2210
|
+
if (cancelled)
|
|
2211
|
+
return;
|
|
2212
|
+
setLoadFailed(true);
|
|
2213
|
+
setPlacesReady(false);
|
|
2214
|
+
});
|
|
2215
|
+
return () => {
|
|
2216
|
+
cancelled = true;
|
|
2217
|
+
};
|
|
2218
|
+
}, [apiKey]);
|
|
2219
|
+
function closeDropdown() {
|
|
2220
|
+
setOpen(false);
|
|
2221
|
+
setHighlightIndex(-1);
|
|
2222
|
+
setPredictions([]);
|
|
2223
|
+
}
|
|
2224
|
+
function fetchPredictions(input) {
|
|
2225
|
+
const service = serviceRef.current;
|
|
2226
|
+
if (!service || input.trim().length === 0) {
|
|
2227
|
+
closeDropdown();
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
service.getPlacePredictions({
|
|
2231
|
+
input,
|
|
2232
|
+
sessionToken: sessionTokenRef.current ?? undefined,
|
|
2233
|
+
types: ['address'],
|
|
2234
|
+
}, (results, status) => {
|
|
2235
|
+
if (!focusedRef.current)
|
|
2236
|
+
return;
|
|
2237
|
+
const okStatus = getLoadedGooglePlacesApi()?.PlacesServiceStatus.OK;
|
|
2238
|
+
if (status !== okStatus || !results || results.length === 0) {
|
|
2239
|
+
closeDropdown();
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
setPredictions(results);
|
|
2243
|
+
setHighlightIndex(-1);
|
|
2244
|
+
setOpen(true);
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
function selectPrediction(prediction) {
|
|
2248
|
+
const placesService = placesServiceRef.current;
|
|
2249
|
+
if (!placesService)
|
|
2250
|
+
return;
|
|
2251
|
+
placesService.getDetails({
|
|
2252
|
+
placeId: prediction.place_id,
|
|
2253
|
+
fields: ['formatted_address', 'place_id'],
|
|
2254
|
+
sessionToken: sessionTokenRef.current ?? undefined,
|
|
2255
|
+
}, (place, status) => {
|
|
2256
|
+
const places = getLoadedGooglePlacesApi();
|
|
2257
|
+
sessionTokenRef.current = places
|
|
2258
|
+
? new places.AutocompleteSessionToken()
|
|
2259
|
+
: null;
|
|
2260
|
+
const okStatus = getLoadedGooglePlacesApi()?.PlacesServiceStatus.OK;
|
|
2261
|
+
const formatted = place?.formatted_address?.trim();
|
|
2262
|
+
if (status !== okStatus || !formatted) {
|
|
2263
|
+
setAcceptedValue('');
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
setAcceptedValue(formatted);
|
|
2267
|
+
onChange(formatted);
|
|
2268
|
+
closeDropdown();
|
|
2269
|
+
inputRef.current?.blur();
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
function handleAddressChange(event) {
|
|
2273
|
+
const next = event.target.value;
|
|
2274
|
+
onChange(next);
|
|
2275
|
+
const trimmed = next.trim();
|
|
2276
|
+
if (!trimmed) {
|
|
2277
|
+
setAcceptedValue('');
|
|
2278
|
+
closeDropdown();
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
if (trimmed !== acceptedValue) {
|
|
2282
|
+
setAcceptedValue('');
|
|
2283
|
+
}
|
|
2284
|
+
if (!placesReady || loadFailed)
|
|
2285
|
+
return;
|
|
2286
|
+
if (debounceRef.current)
|
|
2287
|
+
clearTimeout(debounceRef.current);
|
|
2288
|
+
debounceRef.current = setTimeout(() => fetchPredictions(trimmed), ADDRESS_PREDICTION_DEBOUNCE_MS);
|
|
2289
|
+
}
|
|
2290
|
+
function handleAddressKeyDown(event) {
|
|
2291
|
+
if (!open || predictions.length === 0)
|
|
2292
|
+
return;
|
|
2293
|
+
if (event.key === 'ArrowDown') {
|
|
2294
|
+
event.preventDefault();
|
|
2295
|
+
setHighlightIndex((current) => current < predictions.length - 1 ? current + 1 : 0);
|
|
2296
|
+
}
|
|
2297
|
+
else if (event.key === 'ArrowUp') {
|
|
2298
|
+
event.preventDefault();
|
|
2299
|
+
setHighlightIndex((current) => current > 0 ? current - 1 : predictions.length - 1);
|
|
2300
|
+
}
|
|
2301
|
+
else if (event.key === 'Enter') {
|
|
2302
|
+
if (highlightIndex >= 0 && highlightIndex < predictions.length) {
|
|
2303
|
+
event.preventDefault();
|
|
2304
|
+
selectPrediction(predictions[highlightIndex]);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
else if (event.key === 'Escape') {
|
|
2308
|
+
closeDropdown();
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
const listboxId = `${id}-matches`;
|
|
2312
|
+
const shouldShowInvalid = showInvalid && trimmedValue.length > 0;
|
|
2313
|
+
return (_jsxs("div", { className: "bw-address-autocomplete", children: [_jsx("input", { ref: inputRef, id: id, required: true, value: value, onChange: handleAddressChange, onKeyDown: handleAddressKeyDown, onFocus: () => {
|
|
2314
|
+
focusedRef.current = true;
|
|
2315
|
+
if (predictions.length > 0)
|
|
2316
|
+
setOpen(true);
|
|
2317
|
+
}, onBlur: () => {
|
|
2318
|
+
focusedRef.current = false;
|
|
2319
|
+
setOpen(false);
|
|
2320
|
+
}, role: mapsConfigured ? 'combobox' : undefined, "aria-expanded": mapsConfigured ? open : undefined, "aria-controls": mapsConfigured ? listboxId : undefined, "aria-autocomplete": mapsConfigured ? 'list' : undefined, "aria-invalid": ariaInvalid || shouldShowInvalid || undefined, "aria-describedby": shouldShowInvalid ? `${id}-google-error` : ariaDescribedBy, placeholder: placeholder, autoComplete: "off" }), mapsConfigured ? (_jsx("span", { className: "bw-address-icon", "aria-hidden": "true", children: isGoogleValid && trimmedValue ? _jsx(CheckIcon, {}) : _jsx(LocationPinIcon, {}) })) : null, open && predictions.length > 0 ? (_jsx("div", { id: listboxId, role: "listbox", "aria-label": "Address matches", className: "bw-address-menu", children: predictions.map((prediction, index) => {
|
|
2321
|
+
const main = prediction.structured_formatting?.main_text ??
|
|
2322
|
+
prediction.description;
|
|
2323
|
+
const secondary = prediction.structured_formatting?.secondary_text ?? '';
|
|
2324
|
+
return (_jsxs("button", { type: "button", role: "option", "aria-selected": index === highlightIndex, className: `bw-address-option${index === highlightIndex ? ' is-focused' : ''}`, onMouseDown: (event) => {
|
|
2325
|
+
event.preventDefault();
|
|
2326
|
+
selectPrediction(prediction);
|
|
2327
|
+
}, onMouseEnter: () => setHighlightIndex(index), children: [_jsx("span", { className: "bw-address-option-icon", "aria-hidden": "true", children: _jsx(LocationPinIcon, {}) }), _jsxs("span", { className: "bw-address-option-copy", children: [_jsx("span", { className: "bw-address-option-main", children: main }), secondary ? (_jsx("span", { className: "bw-address-option-secondary", children: secondary })) : null] })] }, prediction.place_id));
|
|
2328
|
+
}) })) : null, shouldShowInvalid ? (_jsx("span", { className: "bw-field-error", id: `${id}-google-error`, children: loadFailed ? lookupUnavailableMessage : invalidMessage })) : null] }));
|
|
2329
|
+
}
|
|
2112
2330
|
function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
|
|
2113
2331
|
const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
|
|
2114
2332
|
return (_jsx(Elements, { stripe: stripePromise, options: {
|
|
@@ -2550,6 +2768,24 @@ function zonedDateTimeToTimestamp(dateKey, time, timezone) {
|
|
|
2550
2768
|
}
|
|
2551
2769
|
return guess;
|
|
2552
2770
|
}
|
|
2771
|
+
function findFirstSelectableFlexibleDate({ calendarMonth, todayDateKey, service, businessHours, timezone, }) {
|
|
2772
|
+
const end = endOfMonth(calendarMonth);
|
|
2773
|
+
const cursor = startOfMonth(calendarMonth);
|
|
2774
|
+
while (cursor <= end) {
|
|
2775
|
+
const dateKey = formatDateKey(cursor);
|
|
2776
|
+
if (dateKey >= todayDateKey &&
|
|
2777
|
+
hasSelectableFlexibleWindowsForDate({
|
|
2778
|
+
dateKey,
|
|
2779
|
+
service,
|
|
2780
|
+
businessHours,
|
|
2781
|
+
timezone,
|
|
2782
|
+
})) {
|
|
2783
|
+
return dateKey;
|
|
2784
|
+
}
|
|
2785
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
2786
|
+
}
|
|
2787
|
+
return null;
|
|
2788
|
+
}
|
|
2553
2789
|
function hasSelectableFlexibleWindowsForDate({ dateKey, service, businessHours, timezone, }) {
|
|
2554
2790
|
return getFlexibleWindowOptionsForDate({ dateKey, businessHours }).some((window) => isFlexibleWindowSelectable({
|
|
2555
2791
|
dateKey,
|