@asksable/site-connector 0.6.6 → 0.6.8

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;AA+MhD;;;;;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,2CAq0GpB"}
@@ -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);
@@ -275,15 +324,29 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
275
324
  const selectedQuoteOverride = selectedService && initialQuoteOverride
276
325
  ? getApplicableInitialQuoteOverride(initialSelection, selectedService, initialQuoteOverride)
277
326
  : null;
327
+ const defaultTaxRate = setup?.defaultTaxRate ?? null;
278
328
  const displayQuote = selectedService && selectedQuoteOverride
279
- ? quoteFromOverride(selectedService, selectedQuoteOverride)
280
- : quote;
329
+ ? quoteFromOverride(selectedService, selectedQuoteOverride, defaultTaxRate)
330
+ : selectedService
331
+ ? (quote ??
332
+ (selectedService.pricingMode === 'calculated'
333
+ ? null
334
+ : quoteFromService(selectedService, defaultTaxRate)))
335
+ : null;
281
336
  const isDisplayQuoteLoading = isQuoteLoading && !displayQuote;
282
337
  const bookingContextRows = useMemo(() => buildBookingContextRows({
283
338
  intakeResponses,
284
339
  quote: displayQuote,
285
340
  locale: intlLocale,
286
341
  }), [displayQuote, intakeResponses, intlLocale]);
342
+ const priceSummaryRows = useMemo(() => buildPriceSummaryRows({
343
+ service: selectedService,
344
+ quote: displayQuote,
345
+ isLoading: isDisplayQuoteLoading,
346
+ locale: intlLocale,
347
+ t,
348
+ }), [displayQuote, intlLocale, isDisplayQuoteLoading, selectedService, t]);
349
+ const totalPriceSummaryRow = priceSummaryRows.find((row) => row.isTotal) ?? priceSummaryRows.at(-1);
287
350
  const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
288
351
  const flexibleSchedulingEnabled = allowFlexibleScheduling && selectedServiceRequiresSlot && !isReschedule;
289
352
  const effectiveSchedulingPreference = flexibleSchedulingEnabled ? schedulingPreference : 'exact';
@@ -335,7 +398,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
335
398
  const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
336
399
  const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
337
400
  const showCustomerPhoneError = submitAttempted && !trimmedCustomerPhone;
338
- const showServiceAddressError = submitAttempted && !isReschedule && !trimmedServiceAddress;
401
+ const showServiceAddressRequiredError = submitAttempted && !isReschedule && !trimmedServiceAddress;
402
+ const showServiceAddressSelectionError = submitAttempted &&
403
+ !isReschedule &&
404
+ trimmedServiceAddress.length > 0 &&
405
+ !isServiceAddressValid;
406
+ const showServiceAddressError = showServiceAddressRequiredError || showServiceAddressSelectionError;
339
407
  // In reschedule mode we pre-select the original service but keep
340
408
  // the full list visible - the admin may legitimately need to
341
409
  // switch service when rescheduling (e.g. customer asked for a
@@ -818,7 +886,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
818
886
  const canAdvanceStep3 = Boolean(selectedService &&
819
887
  customerName.trim() &&
820
888
  isCustomerEmailValid &&
821
- (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress)) &&
889
+ (isReschedule ||
890
+ (trimmedCustomerPhone &&
891
+ trimmedServiceAddress &&
892
+ isServiceAddressValid)) &&
822
893
  isIntakeComplete);
823
894
  // Slots block is rendered in two locations: inline under the calendar
824
895
  // (mobile flow keeps current step-3 behavior) and at the top of the
@@ -866,7 +937,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
866
937
  (selectedService.pricingMode !== 'calculated' || quote) &&
867
938
  customerName.trim() &&
868
939
  isCustomerEmailValid &&
869
- (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
940
+ (isReschedule ||
941
+ (trimmedCustomerPhone &&
942
+ trimmedServiceAddress &&
943
+ isServiceAddressValid))) && !isSubmitting;
870
944
  const submitBlockers = selectedService
871
945
  ? getSubmitBlockers({
872
946
  selectedService,
@@ -880,6 +954,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
880
954
  customerEmail,
881
955
  customerPhone,
882
956
  serviceAddress,
957
+ serviceAddressIsValid: isServiceAddressValid,
883
958
  requireServiceAddress: !isReschedule,
884
959
  t,
885
960
  locale,
@@ -952,7 +1027,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
952
1027
  const emailErrorId = `${emailId}-error`;
953
1028
  const phoneErrorId = `${phoneId}-error`;
954
1029
  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] }));
1030
+ 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
1031
  }
957
1032
  function renderIntakeFields(idPrefix) {
958
1033
  return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
@@ -1213,7 +1288,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1213
1288
  filename: photo.filename,
1214
1289
  data: photo.data,
1215
1290
  })),
1216
- quotedTotalCents: displayQuote?.totalCents,
1291
+ quotedTotalCents: selectedQuoteOverride?.totalCents,
1217
1292
  bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
1218
1293
  ? holdId
1219
1294
  : undefined,
@@ -1470,9 +1545,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1470
1545
  return (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewAdditionalInfo') }), _jsx("div", { className: "bw-summary-rows", children: intakeRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.multiline ? ' bw-summary-row--stack' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] }));
1471
1546
  })(), bookingContextRows.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('bookingContextTitle') }), _jsx("div", { className: "bw-summary-rows", children: bookingContextRows.map((row) => (_jsxs("div", { className: "bw-summary-row bw-summary-row--stack", children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] })) : null, customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
1472
1547
  ? t('notesLabelReschedule')
1473
- : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, !isReschedule && bookingPhotos.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('photosLabel') }), _jsx("p", { className: "bw-summary-notes", children: t('photosCount', { count: bookingPhotos.length }) })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
1474
- ? t('summaryCalculating')
1475
- : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, displayQuote, intlLocale) })] }) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: effectiveSchedulingPreference === 'flexible'
1548
+ : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, !isReschedule && bookingPhotos.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('photosLabel') }), _jsx("p", { className: "bw-summary-notes", children: t('photosCount', { count: bookingPhotos.length }) })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: priceSummaryRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.isTotal ? ' bw-summary-total' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: row.value })] }, row.key))) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: effectiveSchedulingPreference === 'flexible'
1476
1549
  ? t('flexWindowHeading')
1477
1550
  : t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children: selectedDateFlexibleWindows.length })) : selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake', ''), renderBookingContext(), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), renderPhotoField('bw-photos'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
1478
1551
  ? t('summaryTitleReschedule')
@@ -1490,9 +1563,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1490
1563
  opacity: 0.55,
1491
1564
  }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ?? selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ??
1492
1565
  selectedStaff?.name ??
1493
- t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
1494
- ? t('summaryCalculating')
1495
- : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] })] })) : null, error ? (_jsx("div", { className: "bw-error", children: error })) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
1566
+ t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), priceSummaryRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.isTotal ? ' bw-summary-total' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: row.value })] }, row.key)))] })] })) : null, error ? (_jsx("div", { className: "bw-error", children: error })) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
1496
1567
  setSuccess(t('successPaymentReceived'));
1497
1568
  setPayment(null);
1498
1569
  }, onError: setError })) : (_jsxs("button", { type: "button", className: `bw-confirm-btn${!canSubmit || isDisplayQuoteLoading ? ' is-disabled' : ''}`, "aria-disabled": !canSubmit || isDisplayQuoteLoading, onClick: handleConfirmAttempt, children: [_jsx("span", { children: isSubmitting
@@ -1509,9 +1580,12 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1509
1580
  ? t('summaryNewTime')
1510
1581
  : t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}), selectedTimeLabel] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ??
1511
1582
  selectedStaff?.name ??
1512
- t('providerAny') })] }), !isReschedule && serviceAddress.trim() ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(LocationPinIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: serviceAddress.trim() })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: bookingTimezone })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isDisplayQuoteLoading
1513
- ? t('summaryCalculating')
1514
- : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderBookingContext(), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
1583
+ t('providerAny') })] }), !isReschedule && serviceAddress.trim() ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(LocationPinIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: serviceAddress.trim() })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: bookingTimezone })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: totalPriceSummaryRow?.label ??
1584
+ t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: totalPriceSummaryRow?.value ??
1585
+ formatServicePrice(selectedService, displayQuote, intlLocale) }), priceSummaryRows.length > 1 ? (_jsx("span", { className: "bw-details-meta-subvalue", children: priceSummaryRows
1586
+ .filter((row) => !row.isTotal)
1587
+ .map((row) => `${row.label}: ${row.value}`)
1588
+ .join(' · ') })) : null] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderBookingContext(), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
1515
1589
  ? t('notesPlaceholderReschedule')
1516
1590
  : t('notesPlaceholder')), renderPhotoField('bw-photos-d'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
1517
1591
  (forcedState === 'payment-full' ||
@@ -1623,7 +1697,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1623
1697
  return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1624
1698
  }));
1625
1699
  }
1626
- function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
1700
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, serviceAddressIsValid, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
1627
1701
  const blockers = [];
1628
1702
  if (selectedServiceRequiresSlot && !hasScheduleSelection) {
1629
1703
  blockers.push(t('blockerDateTime'));
@@ -1641,6 +1715,9 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
1641
1715
  if (requireServiceAddress && !serviceAddress.trim()) {
1642
1716
  blockers.push(t('blockerServiceAddress'));
1643
1717
  }
1718
+ else if (requireServiceAddress && !serviceAddressIsValid) {
1719
+ blockers.push(t('blockerServiceAddressInvalid'));
1720
+ }
1644
1721
  if (!isIntakeComplete && selectedService.intakeForm) {
1645
1722
  blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1646
1723
  }
@@ -1789,14 +1866,12 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1789
1866
  }
1790
1867
  function buildBookingContextRows({ intakeResponses, quote, locale, }) {
1791
1868
  const rows = [];
1792
- const usedKeys = new Set();
1793
1869
  const addKnown = (keys, label, formatter = formatContextValue) => {
1794
1870
  for (const key of keys) {
1795
1871
  const value = intakeResponses[key];
1796
1872
  const formatted = formatter(value);
1797
1873
  if (formatted) {
1798
1874
  rows.push({ key, label, value: formatted });
1799
- usedKeys.add(key);
1800
1875
  return;
1801
1876
  }
1802
1877
  }
@@ -1822,24 +1897,6 @@ function buildBookingContextRows({ intakeResponses, quote, locale, }) {
1822
1897
  label: 'Website estimate',
1823
1898
  value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
1824
1899
  });
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
1900
  }
1844
1901
  return rows;
1845
1902
  }
@@ -1887,9 +1944,6 @@ function titleizeContextValue(value) {
1887
1944
  .trim()
1888
1945
  .replace(/\b\w/g, (char) => char.toUpperCase());
1889
1946
  }
1890
- function formatContextKey(key) {
1891
- return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
1892
- }
1893
1947
  function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
1894
1948
  const sections = [];
1895
1949
  if (contextRows.length > 0) {
@@ -2120,6 +2174,172 @@ function IntakeField({ field, value, onChange, idPrefix, }) {
2120
2174
  : event.target.value);
2121
2175
  } }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
2122
2176
  }
2177
+ const ADDRESS_PREDICTION_DEBOUNCE_MS = 180;
2178
+ function BookingAddressAutocompleteInput({ id, value, onChange, onValidityChange, mapsApiKey, placeholder, ariaInvalid, ariaDescribedBy, showInvalid, invalidMessage, lookupUnavailableMessage, }) {
2179
+ const inputRef = useRef(null);
2180
+ const debounceRef = useRef(null);
2181
+ const serviceRef = useRef(null);
2182
+ const placesServiceRef = useRef(null);
2183
+ const sessionTokenRef = useRef(null);
2184
+ const focusedRef = useRef(false);
2185
+ const [acceptedValue, setAcceptedValue] = useState('');
2186
+ const [predictions, setPredictions] = useState([]);
2187
+ const [highlightIndex, setHighlightIndex] = useState(-1);
2188
+ const [open, setOpen] = useState(false);
2189
+ const [loadFailed, setLoadFailed] = useState(false);
2190
+ const [placesReady, setPlacesReady] = useState(false);
2191
+ const apiKey = mapsApiKey?.trim() || null;
2192
+ const mapsConfigured = Boolean(apiKey);
2193
+ const needsGoogleSelection = mapsConfigured;
2194
+ const trimmedValue = value.trim();
2195
+ const isGoogleValid = !needsGoogleSelection ||
2196
+ trimmedValue.length === 0 ||
2197
+ acceptedValue === trimmedValue;
2198
+ useEffect(() => {
2199
+ onValidityChange(isGoogleValid);
2200
+ }, [isGoogleValid, onValidityChange]);
2201
+ useEffect(() => () => {
2202
+ if (debounceRef.current)
2203
+ clearTimeout(debounceRef.current);
2204
+ }, []);
2205
+ useEffect(() => {
2206
+ if (!apiKey) {
2207
+ setPlacesReady(false);
2208
+ setLoadFailed(false);
2209
+ return;
2210
+ }
2211
+ let cancelled = false;
2212
+ void loadGooglePlacesApi(apiKey)
2213
+ .then((places) => {
2214
+ if (cancelled)
2215
+ return;
2216
+ serviceRef.current = new places.AutocompleteService();
2217
+ placesServiceRef.current = new places.PlacesService(document.createElement('div'));
2218
+ sessionTokenRef.current = new places.AutocompleteSessionToken();
2219
+ setLoadFailed(false);
2220
+ setPlacesReady(true);
2221
+ })
2222
+ .catch(() => {
2223
+ if (cancelled)
2224
+ return;
2225
+ setLoadFailed(true);
2226
+ setPlacesReady(false);
2227
+ });
2228
+ return () => {
2229
+ cancelled = true;
2230
+ };
2231
+ }, [apiKey]);
2232
+ function closeDropdown() {
2233
+ setOpen(false);
2234
+ setHighlightIndex(-1);
2235
+ setPredictions([]);
2236
+ }
2237
+ function fetchPredictions(input) {
2238
+ const service = serviceRef.current;
2239
+ if (!service || input.trim().length === 0) {
2240
+ closeDropdown();
2241
+ return;
2242
+ }
2243
+ service.getPlacePredictions({
2244
+ input,
2245
+ sessionToken: sessionTokenRef.current ?? undefined,
2246
+ types: ['address'],
2247
+ }, (results, status) => {
2248
+ if (!focusedRef.current)
2249
+ return;
2250
+ const okStatus = getLoadedGooglePlacesApi()?.PlacesServiceStatus.OK;
2251
+ if (status !== okStatus || !results || results.length === 0) {
2252
+ closeDropdown();
2253
+ return;
2254
+ }
2255
+ setPredictions(results);
2256
+ setHighlightIndex(-1);
2257
+ setOpen(true);
2258
+ });
2259
+ }
2260
+ function selectPrediction(prediction) {
2261
+ const placesService = placesServiceRef.current;
2262
+ if (!placesService)
2263
+ return;
2264
+ placesService.getDetails({
2265
+ placeId: prediction.place_id,
2266
+ fields: ['formatted_address', 'place_id'],
2267
+ sessionToken: sessionTokenRef.current ?? undefined,
2268
+ }, (place, status) => {
2269
+ const places = getLoadedGooglePlacesApi();
2270
+ sessionTokenRef.current = places
2271
+ ? new places.AutocompleteSessionToken()
2272
+ : null;
2273
+ const okStatus = getLoadedGooglePlacesApi()?.PlacesServiceStatus.OK;
2274
+ const formatted = place?.formatted_address?.trim();
2275
+ if (status !== okStatus || !formatted) {
2276
+ setAcceptedValue('');
2277
+ return;
2278
+ }
2279
+ setAcceptedValue(formatted);
2280
+ onChange(formatted);
2281
+ closeDropdown();
2282
+ inputRef.current?.blur();
2283
+ });
2284
+ }
2285
+ function handleAddressChange(event) {
2286
+ const next = event.target.value;
2287
+ onChange(next);
2288
+ const trimmed = next.trim();
2289
+ if (!trimmed) {
2290
+ setAcceptedValue('');
2291
+ closeDropdown();
2292
+ return;
2293
+ }
2294
+ if (trimmed !== acceptedValue) {
2295
+ setAcceptedValue('');
2296
+ }
2297
+ if (!placesReady || loadFailed)
2298
+ return;
2299
+ if (debounceRef.current)
2300
+ clearTimeout(debounceRef.current);
2301
+ debounceRef.current = setTimeout(() => fetchPredictions(trimmed), ADDRESS_PREDICTION_DEBOUNCE_MS);
2302
+ }
2303
+ function handleAddressKeyDown(event) {
2304
+ if (!open || predictions.length === 0)
2305
+ return;
2306
+ if (event.key === 'ArrowDown') {
2307
+ event.preventDefault();
2308
+ setHighlightIndex((current) => current < predictions.length - 1 ? current + 1 : 0);
2309
+ }
2310
+ else if (event.key === 'ArrowUp') {
2311
+ event.preventDefault();
2312
+ setHighlightIndex((current) => current > 0 ? current - 1 : predictions.length - 1);
2313
+ }
2314
+ else if (event.key === 'Enter') {
2315
+ if (highlightIndex >= 0 && highlightIndex < predictions.length) {
2316
+ event.preventDefault();
2317
+ selectPrediction(predictions[highlightIndex]);
2318
+ }
2319
+ }
2320
+ else if (event.key === 'Escape') {
2321
+ closeDropdown();
2322
+ }
2323
+ }
2324
+ const listboxId = `${id}-matches`;
2325
+ const shouldShowInvalid = showInvalid && trimmedValue.length > 0;
2326
+ return (_jsxs("div", { className: "bw-address-autocomplete", children: [_jsx("input", { ref: inputRef, id: id, required: true, value: value, onChange: handleAddressChange, onKeyDown: handleAddressKeyDown, onFocus: () => {
2327
+ focusedRef.current = true;
2328
+ if (predictions.length > 0)
2329
+ setOpen(true);
2330
+ }, onBlur: () => {
2331
+ focusedRef.current = false;
2332
+ setOpen(false);
2333
+ }, 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) => {
2334
+ const main = prediction.structured_formatting?.main_text ??
2335
+ prediction.description;
2336
+ const secondary = prediction.structured_formatting?.secondary_text ?? '';
2337
+ return (_jsxs("button", { type: "button", role: "option", "aria-selected": index === highlightIndex, className: `bw-address-option${index === highlightIndex ? ' is-focused' : ''}`, onMouseDown: (event) => {
2338
+ event.preventDefault();
2339
+ selectPrediction(prediction);
2340
+ }, 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));
2341
+ }) })) : null, shouldShowInvalid ? (_jsx("span", { className: "bw-field-error", id: `${id}-google-error`, children: loadFailed ? lookupUnavailableMessage : invalidMessage })) : null] }));
2342
+ }
2123
2343
  function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
2124
2344
  const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
2125
2345
  return (_jsx(Elements, { stripe: stripePromise, options: {
@@ -2171,21 +2391,91 @@ function formatServicePrice(service, quote, locale = 'en-US') {
2171
2391
  }
2172
2392
  return currencyFormatter(service.currency, locale).format(service.priceCents / 100);
2173
2393
  }
2174
- function quoteFromOverride(service, override) {
2394
+ function buildPriceSummaryRows({ service, quote, isLoading, locale, t, }) {
2395
+ if (!service)
2396
+ return [];
2397
+ const totalLabel = isLoading
2398
+ ? t('summaryCalculating')
2399
+ : t('summaryEstimatedTotal');
2400
+ const totalValue = formatServicePrice(service, quote, locale);
2401
+ if (!quote || (quote.taxCents ?? 0) <= 0) {
2402
+ return [
2403
+ {
2404
+ key: 'total',
2405
+ label: totalLabel,
2406
+ value: totalValue,
2407
+ isTotal: true,
2408
+ },
2409
+ ];
2410
+ }
2411
+ const formatter = currencyFormatter(quote.currency, locale);
2412
+ const taxLabel = quote.taxRateBps
2413
+ ? `${quote.taxRateName ?? t('summarySalesTax')} (${formatPercentBps(quote.taxRateBps)})`
2414
+ : (quote.taxRateName ?? t('summarySalesTax'));
2415
+ return [
2416
+ {
2417
+ key: 'subtotal',
2418
+ label: t('summarySubtotal'),
2419
+ value: formatter.format(quote.subtotalCents / 100),
2420
+ },
2421
+ {
2422
+ key: 'tax',
2423
+ label: taxLabel,
2424
+ value: formatter.format((quote.taxCents ?? 0) / 100),
2425
+ },
2426
+ {
2427
+ key: 'total',
2428
+ label: totalLabel,
2429
+ value: totalValue,
2430
+ isTotal: true,
2431
+ },
2432
+ ];
2433
+ }
2434
+ function formatPercentBps(rateBps) {
2435
+ const percent = rateBps / 100;
2436
+ return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}%`;
2437
+ }
2438
+ function quoteFromOverride(service, override, taxRate) {
2439
+ return buildDisplayQuote({
2440
+ service,
2441
+ subtotalCents: override.totalCents,
2442
+ taxRate,
2443
+ displayLabel: taxRate ? undefined : override.label,
2444
+ rateSnapshot: {
2445
+ quotedTotalCents: override.totalCents,
2446
+ quoteSource: 'host-estimator',
2447
+ },
2448
+ });
2449
+ }
2450
+ function quoteFromService(service, taxRate) {
2451
+ return buildDisplayQuote({
2452
+ service,
2453
+ subtotalCents: service.priceCents,
2454
+ taxRate,
2455
+ });
2456
+ }
2457
+ function buildDisplayQuote({ service, subtotalCents, taxRate, displayLabel, rateSnapshot, }) {
2458
+ const taxRateBps = taxRate?.rateBps ?? 0;
2459
+ const taxCents = Math.round((subtotalCents * taxRateBps) / 10_000);
2175
2460
  return {
2176
2461
  currency: service.currency || 'usd',
2177
- subtotalCents: override.totalCents,
2462
+ subtotalCents,
2178
2463
  discountCents: 0,
2179
2464
  insuranceCents: 0,
2180
2465
  additionalFeesCents: 0,
2181
- totalCents: override.totalCents,
2466
+ taxCents,
2467
+ taxRateName: taxRate?.name,
2468
+ taxRateBps: taxRate?.rateBps,
2469
+ totalCents: subtotalCents + taxCents,
2182
2470
  rateSnapshot: {
2183
2471
  pricingMode: 'fixed',
2184
2472
  servicePriceCents: service.priceCents,
2185
- quotedTotalCents: override.totalCents,
2186
- quoteSource: 'host-estimator',
2473
+ taxCents,
2474
+ taxRateName: taxRate?.name,
2475
+ taxRateBps: taxRate?.rateBps,
2476
+ ...(rateSnapshot ?? {}),
2187
2477
  },
2188
- displayLabel: override.label,
2478
+ displayLabel,
2189
2479
  };
2190
2480
  }
2191
2481
  function getInitialQuoteOverride(initialSelection) {