@asksable/site-connector 0.6.6 → 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 +235 -28
- 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
|
|
@@ -818,7 +872,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
818
872
|
const canAdvanceStep3 = Boolean(selectedService &&
|
|
819
873
|
customerName.trim() &&
|
|
820
874
|
isCustomerEmailValid &&
|
|
821
|
-
(isReschedule ||
|
|
875
|
+
(isReschedule ||
|
|
876
|
+
(trimmedCustomerPhone &&
|
|
877
|
+
trimmedServiceAddress &&
|
|
878
|
+
isServiceAddressValid)) &&
|
|
822
879
|
isIntakeComplete);
|
|
823
880
|
// Slots block is rendered in two locations: inline under the calendar
|
|
824
881
|
// (mobile flow keeps current step-3 behavior) and at the top of the
|
|
@@ -866,7 +923,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
866
923
|
(selectedService.pricingMode !== 'calculated' || quote) &&
|
|
867
924
|
customerName.trim() &&
|
|
868
925
|
isCustomerEmailValid &&
|
|
869
|
-
(isReschedule ||
|
|
926
|
+
(isReschedule ||
|
|
927
|
+
(trimmedCustomerPhone &&
|
|
928
|
+
trimmedServiceAddress &&
|
|
929
|
+
isServiceAddressValid))) && !isSubmitting;
|
|
870
930
|
const submitBlockers = selectedService
|
|
871
931
|
? getSubmitBlockers({
|
|
872
932
|
selectedService,
|
|
@@ -880,6 +940,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
880
940
|
customerEmail,
|
|
881
941
|
customerPhone,
|
|
882
942
|
serviceAddress,
|
|
943
|
+
serviceAddressIsValid: isServiceAddressValid,
|
|
883
944
|
requireServiceAddress: !isReschedule,
|
|
884
945
|
t,
|
|
885
946
|
locale,
|
|
@@ -952,7 +1013,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
952
1013
|
const emailErrorId = `${emailId}-error`;
|
|
953
1014
|
const phoneErrorId = `${phoneId}-error`;
|
|
954
1015
|
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(
|
|
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] }));
|
|
956
1017
|
}
|
|
957
1018
|
function renderIntakeFields(idPrefix) {
|
|
958
1019
|
return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
|
|
@@ -1623,7 +1684,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
|
|
|
1623
1684
|
return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
|
|
1624
1685
|
}));
|
|
1625
1686
|
}
|
|
1626
|
-
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, }) {
|
|
1627
1688
|
const blockers = [];
|
|
1628
1689
|
if (selectedServiceRequiresSlot && !hasScheduleSelection) {
|
|
1629
1690
|
blockers.push(t('blockerDateTime'));
|
|
@@ -1641,6 +1702,9 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
|
|
|
1641
1702
|
if (requireServiceAddress && !serviceAddress.trim()) {
|
|
1642
1703
|
blockers.push(t('blockerServiceAddress'));
|
|
1643
1704
|
}
|
|
1705
|
+
else if (requireServiceAddress && !serviceAddressIsValid) {
|
|
1706
|
+
blockers.push(t('blockerServiceAddressInvalid'));
|
|
1707
|
+
}
|
|
1644
1708
|
if (!isIntakeComplete && selectedService.intakeForm) {
|
|
1645
1709
|
blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
|
|
1646
1710
|
}
|
|
@@ -1789,14 +1853,12 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
|
|
|
1789
1853
|
}
|
|
1790
1854
|
function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
1791
1855
|
const rows = [];
|
|
1792
|
-
const usedKeys = new Set();
|
|
1793
1856
|
const addKnown = (keys, label, formatter = formatContextValue) => {
|
|
1794
1857
|
for (const key of keys) {
|
|
1795
1858
|
const value = intakeResponses[key];
|
|
1796
1859
|
const formatted = formatter(value);
|
|
1797
1860
|
if (formatted) {
|
|
1798
1861
|
rows.push({ key, label, value: formatted });
|
|
1799
|
-
usedKeys.add(key);
|
|
1800
1862
|
return;
|
|
1801
1863
|
}
|
|
1802
1864
|
}
|
|
@@ -1822,24 +1884,6 @@ function buildBookingContextRows({ intakeResponses, quote, locale, }) {
|
|
|
1822
1884
|
label: 'Website estimate',
|
|
1823
1885
|
value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
|
|
1824
1886
|
});
|
|
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
1887
|
}
|
|
1844
1888
|
return rows;
|
|
1845
1889
|
}
|
|
@@ -1887,9 +1931,6 @@ function titleizeContextValue(value) {
|
|
|
1887
1931
|
.trim()
|
|
1888
1932
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1889
1933
|
}
|
|
1890
|
-
function formatContextKey(key) {
|
|
1891
|
-
return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
1892
|
-
}
|
|
1893
1934
|
function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
|
|
1894
1935
|
const sections = [];
|
|
1895
1936
|
if (contextRows.length > 0) {
|
|
@@ -2120,6 +2161,172 @@ function IntakeField({ field, value, onChange, idPrefix, }) {
|
|
|
2120
2161
|
: event.target.value);
|
|
2121
2162
|
} }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
|
|
2122
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
|
+
}
|
|
2123
2330
|
function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
|
|
2124
2331
|
const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
|
|
2125
2332
|
return (_jsx(Elements, { stripe: stripePromise, options: {
|