@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.
@@ -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;AAsGhD;;;;;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,2CA2xGpB"}
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"}
@@ -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 showServiceAddressError = submitAttempted && !isReschedule && !trimmedServiceAddress;
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 || (trimmedCustomerPhone && trimmedServiceAddress)) &&
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 || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
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("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] }));
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: {