@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.
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +336 -46
- package/dist/booking-widget.js.map +1 -1
- package/dist/styles.css +111 -0
- package/dist/translations.d.ts +10 -0
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +10 -0
- package/dist/translations.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-widget.d.ts","sourceRoot":"","sources":["../src/booking-widget.tsx"],"names":[],"mappings":"AAaA,OAAO,qBAAqB,CAAA;AAc5B,OAAO,KAAK,EAKV,SAAS,EACV,MAAM,OAAO,CAAA;AAcd,KAAK,oBAAoB,GAAG,OAAO,GAAG,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"booking-widget.d.ts","sourceRoot":"","sources":["../src/booking-widget.tsx"],"names":[],"mappings":"AAaA,OAAO,qBAAqB,CAAA;AAc5B,OAAO,KAAK,EAKV,SAAS,EACV,MAAM,OAAO,CAAA;AAcd,KAAK,oBAAoB,GAAG,OAAO,GAAG,UAAU,CAAA;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"}
|
package/dist/booking-widget.js
CHANGED
|
@@ -37,6 +37,7 @@ const MAX_BOOKING_PHOTO_BASE64_LENGTH = 1_500_000;
|
|
|
37
37
|
const MAX_BOOKING_PHOTO_EDGE = 1600;
|
|
38
38
|
const BOOKING_PHOTO_JPEG_QUALITY = 0.78;
|
|
39
39
|
const stripePromises = new Map();
|
|
40
|
+
const googlePlacesPromises = new Map();
|
|
40
41
|
function getStripePromise(publishableKey, connectAccountId) {
|
|
41
42
|
const key = `${publishableKey}:${connectAccountId}`;
|
|
42
43
|
const cached = stripePromises.get(key);
|
|
@@ -48,6 +49,53 @@ function getStripePromise(publishableKey, connectAccountId) {
|
|
|
48
49
|
stripePromises.set(key, promise);
|
|
49
50
|
return promise;
|
|
50
51
|
}
|
|
52
|
+
function getLoadedGooglePlacesApi() {
|
|
53
|
+
if (typeof window === 'undefined')
|
|
54
|
+
return null;
|
|
55
|
+
return (window.google?.maps?.places ?? null);
|
|
56
|
+
}
|
|
57
|
+
function loadGooglePlacesApi(apiKey) {
|
|
58
|
+
const key = apiKey.trim();
|
|
59
|
+
if (!key) {
|
|
60
|
+
return Promise.reject(new Error('Missing Google Maps browser key.'));
|
|
61
|
+
}
|
|
62
|
+
const loaded = getLoadedGooglePlacesApi();
|
|
63
|
+
if (loaded)
|
|
64
|
+
return Promise.resolve(loaded);
|
|
65
|
+
const cached = googlePlacesPromises.get(key);
|
|
66
|
+
if (cached)
|
|
67
|
+
return cached;
|
|
68
|
+
const promise = new Promise((resolve, reject) => {
|
|
69
|
+
if (typeof document === 'undefined') {
|
|
70
|
+
reject(new Error('Google Maps can only load in the browser.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const script = document.createElement('script');
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
key,
|
|
76
|
+
libraries: 'places',
|
|
77
|
+
v: 'weekly',
|
|
78
|
+
});
|
|
79
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
|
|
80
|
+
script.async = true;
|
|
81
|
+
script.defer = true;
|
|
82
|
+
script.onload = () => {
|
|
83
|
+
const next = getLoadedGooglePlacesApi();
|
|
84
|
+
if (next) {
|
|
85
|
+
resolve(next);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
reject(new Error('Google Places failed to initialize.'));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
script.onerror = () => {
|
|
92
|
+
reject(new Error('Google Places failed to load.'));
|
|
93
|
+
};
|
|
94
|
+
document.head.appendChild(script);
|
|
95
|
+
});
|
|
96
|
+
googlePlacesPromises.set(key, promise);
|
|
97
|
+
return promise;
|
|
98
|
+
}
|
|
51
99
|
function isDevRuntime() {
|
|
52
100
|
const env = import.meta.env;
|
|
53
101
|
return env?.DEV === true || env?.MODE === 'development';
|
|
@@ -116,6 +164,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
116
164
|
const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
|
|
117
165
|
const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
|
|
118
166
|
const [serviceAddress, setServiceAddress] = useState('');
|
|
167
|
+
const [isServiceAddressValid, setIsServiceAddressValid] = useState(true);
|
|
119
168
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
120
169
|
const [bookingPhotos, setBookingPhotos] = useState([]);
|
|
121
170
|
const [isPreparingPhotos, setIsPreparingPhotos] = useState(false);
|
|
@@ -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
|
-
:
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
|
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
|
|
2462
|
+
subtotalCents,
|
|
2178
2463
|
discountCents: 0,
|
|
2179
2464
|
insuranceCents: 0,
|
|
2180
2465
|
additionalFeesCents: 0,
|
|
2181
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2473
|
+
taxCents,
|
|
2474
|
+
taxRateName: taxRate?.name,
|
|
2475
|
+
taxRateBps: taxRate?.rateBps,
|
|
2476
|
+
...(rateSnapshot ?? {}),
|
|
2187
2477
|
},
|
|
2188
|
-
displayLabel
|
|
2478
|
+
displayLabel,
|
|
2189
2479
|
};
|
|
2190
2480
|
}
|
|
2191
2481
|
function getInitialQuoteOverride(initialSelection) {
|