@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.
@@ -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,2CAgxGpB"}
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
@@ -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
- if (selectedDate &&
441
- !hasSelectableFlexibleWindowsForDate({
442
- dateKey: selectedDate,
443
- service: selectedService,
444
- businessHours: setup?.businessHours ?? [],
445
- timezone: bookingTimezone,
446
- })) {
447
- setSelectedDate(null);
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 || (trimmedCustomerPhone && trimmedServiceAddress)) &&
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 || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
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("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] }));
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,