@asksable/site-connector 0.4.3 → 0.6.1
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/README.md +26 -5
- package/dist/bones/registry.js +1 -1
- package/dist/booking-widget.d.ts +25 -5
- package/dist/booking-widget.d.ts.map +1 -1
- package/dist/booking-widget.js +69 -29
- package/dist/booking-widget.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +84 -66
- package/dist/translations.d.ts +2 -2
- package/dist/translations.js +2 -2
- package/dist/types.d.ts +25 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,27 @@ import {
|
|
|
82
82
|
} from '@asksable/site-connector'
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Preselecting From A Host Estimator
|
|
86
|
+
|
|
87
|
+
When the host page already knows what the customer is booking, pass an
|
|
88
|
+
`initialSelection`. The widget resolves `serviceSlug` after booking setup loads,
|
|
89
|
+
shows the selected service card, keeps the Change affordance available, and
|
|
90
|
+
sends `intakeResponses` with the final booking.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<BookingWidgetPanel
|
|
94
|
+
initialSelection={{
|
|
95
|
+
serviceSlug: 'interior-detailing',
|
|
96
|
+
customerNotes: 'Estimate shown: $199',
|
|
97
|
+
intakeResponses: {
|
|
98
|
+
vehicle_size: 'large',
|
|
99
|
+
pet_hair: 'minimal',
|
|
100
|
+
estimate_cents: 19900,
|
|
101
|
+
},
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
```
|
|
105
|
+
|
|
85
106
|
## Exports
|
|
86
107
|
|
|
87
108
|
- `createSablePublicClient`
|
|
@@ -95,7 +116,7 @@ import {
|
|
|
95
116
|
- `BookingWidgetPlaceholder`
|
|
96
117
|
- `getResolvedSiteProfile`
|
|
97
118
|
- `createTranslator`, `pickLocaleField`, `localeToIntl`, `TRANSLATIONS`, `DEFAULT_LOCALE`
|
|
98
|
-
- types: `SableSiteConfig`, `Locale`, `TranslationKey`, `TranslationOverrides`, plus public site / booking payloads
|
|
119
|
+
- types: `SableSiteConfig`, `BookingInitialSelection`, `Locale`, `TranslationKey`, `TranslationOverrides`, plus public site / booking payloads
|
|
99
120
|
|
|
100
121
|
## Layout-stable loading
|
|
101
122
|
|
|
@@ -152,7 +173,7 @@ function App() {
|
|
|
152
173
|
}
|
|
153
174
|
```
|
|
154
175
|
|
|
155
|
-
The widget responds instantly to language changes
|
|
176
|
+
The widget responds instantly to language changes. Your toggle component flips both the host site's text and the widget by sharing the same `language` state.
|
|
156
177
|
|
|
157
178
|
### Override individual strings (rare)
|
|
158
179
|
|
|
@@ -181,8 +202,8 @@ Form labels, button text, mobile step labels, helper text, success/cancelled sta
|
|
|
181
202
|
|
|
182
203
|
### What the widget does NOT translate
|
|
183
204
|
|
|
184
|
-
- **Service names, descriptions, category names
|
|
185
|
-
- **Customer-typed input
|
|
205
|
+
- **Service names, descriptions, category names**: these come from the Sable workspace. The widget reads `nameEn` / `nameEs` (or any `${field}En` / `${field}Es`) fields when available, falling back to the base field. If the workspace only entered one locale, that text renders regardless of UI language. (Future workstream: dashboard support for entering both locales.)
|
|
206
|
+
- **Customer-typed input**: names, notes, etc.
|
|
186
207
|
|
|
187
208
|
### Detecting locale in custom components
|
|
188
209
|
|
|
@@ -199,7 +220,7 @@ function MyComponent() {
|
|
|
199
220
|
|
|
200
221
|
### For template builders
|
|
201
222
|
|
|
202
|
-
Every Sable website template should include the `language` prop wiring as part of the boilerplate. If the template supports a toggle, the toggle component must flip both the host site's text and the widget by sharing the same language state. **Never let the widget and host site drift to different locales
|
|
223
|
+
Every Sable website template should include the `language` prop wiring as part of the boilerplate. If the template supports a toggle, the toggle component must flip both the host site's text and the widget by sharing the same language state. **Never let the widget and host site drift to different locales**. Pass a single reactive `language` value into `SableSiteConfig` and the widget stays in sync automatically.
|
|
203
224
|
|
|
204
225
|
## Public API Contract
|
|
205
226
|
|
package/dist/bones/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
// Auto-generated by `npx boneyard-js build`
|
|
2
|
+
// Auto-generated by `npx boneyard-js build` - do not edit
|
|
3
3
|
import { registerBones } from 'boneyard-js';
|
|
4
4
|
import { configureBoneyard } from 'boneyard-js/react';
|
|
5
5
|
import _booking_widget from './booking-widget.bones.json';
|
package/dist/booking-widget.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type BookingRescheduleContext = {
|
|
|
13
13
|
* giving up. */
|
|
14
14
|
formerStartTime: number;
|
|
15
15
|
formerEndTime: number;
|
|
16
|
-
/** Locked service for the reschedule
|
|
16
|
+
/** Locked service for the reschedule - cannot be changed without
|
|
17
17
|
* cancelling and re-booking. */
|
|
18
18
|
serviceId: string;
|
|
19
19
|
/** Optional staff lock. When set, the staff picker is pre-filled
|
|
@@ -29,6 +29,21 @@ export type BookingRescheduleContext = {
|
|
|
29
29
|
* from the original appointment record (host display name). */
|
|
30
30
|
rescheduledByName?: string;
|
|
31
31
|
};
|
|
32
|
+
export type BookingInitialSelection = {
|
|
33
|
+
/** Prefer an exact Sable service id when the host already has it. */
|
|
34
|
+
serviceId?: string;
|
|
35
|
+
/** Host-friendly fallback, e.g. `interior-detailing`. */
|
|
36
|
+
serviceSlug?: string;
|
|
37
|
+
/** Optional staff/provider preselection. The user can still change it. */
|
|
38
|
+
staffMemberId?: string;
|
|
39
|
+
/** Optional customer-facing note carried into the booking request. */
|
|
40
|
+
customerNotes?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Service-specific intake answers supplied by a host estimator or product
|
|
43
|
+
* page before the customer lands in the booking widget.
|
|
44
|
+
*/
|
|
45
|
+
intakeResponses?: Record<string, unknown>;
|
|
46
|
+
};
|
|
32
47
|
type BookingWidgetProps = {
|
|
33
48
|
title?: string;
|
|
34
49
|
description?: string;
|
|
@@ -39,7 +54,7 @@ type BookingWidgetProps = {
|
|
|
39
54
|
* from `rescheduleContext`, swaps the CTA copy, and routes the
|
|
40
55
|
* submit through `onRescheduleSubmit` instead of the public-create
|
|
41
56
|
* mutation. Adding this as an opt-in prop keeps the npm package
|
|
42
|
-
* fully backward compatible
|
|
57
|
+
* fully backward compatible - every existing consumer that omits
|
|
43
58
|
* `mode` continues to behave exactly as before.
|
|
44
59
|
*/
|
|
45
60
|
mode?: 'create' | 'reschedule';
|
|
@@ -55,6 +70,11 @@ type BookingWidgetProps = {
|
|
|
55
70
|
newStartTime: number;
|
|
56
71
|
newEndTime: number;
|
|
57
72
|
}) => Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Optional create-mode preselection. Used when a host page already knows the
|
|
75
|
+
* service and configuration, e.g. an estimator's "Book this detail" CTA.
|
|
76
|
+
*/
|
|
77
|
+
initialSelection?: BookingInitialSelection;
|
|
58
78
|
/**
|
|
59
79
|
* Destination for the success-state CTA after a new booking is submitted.
|
|
60
80
|
* Defaults to the host site's home page so embedded sites don't trap the
|
|
@@ -65,13 +85,13 @@ type BookingWidgetProps = {
|
|
|
65
85
|
* Dev-only: forces the widget into a terminal state without
|
|
66
86
|
* calling any backend mutation. Used by the dev preview page to
|
|
67
87
|
* iterate on success/cancel visuals without spinning through the
|
|
68
|
-
* form each time. Strip from prod surfaces
|
|
88
|
+
* form each time. Strip from prod surfaces - these never call any
|
|
69
89
|
* backend.
|
|
70
90
|
*
|
|
71
91
|
* - `'success'`: renders the "You're All Set!" view (or the
|
|
72
92
|
* reschedule variant when `mode === 'reschedule'`).
|
|
73
93
|
* - `'cancelled'`: renders the "Booking cancelled" view (preview
|
|
74
|
-
* only
|
|
94
|
+
* only - the customer-side cancel mutation isn't built yet).
|
|
75
95
|
*
|
|
76
96
|
* The legacy `__devForceSuccess` boolean is still accepted for
|
|
77
97
|
* back-compat with surfaces that use the old prop.
|
|
@@ -79,6 +99,6 @@ type BookingWidgetProps = {
|
|
|
79
99
|
__devForceState?: 'success' | 'cancelled' | 'payment-full' | 'payment-deposit';
|
|
80
100
|
__devForceSuccess?: boolean;
|
|
81
101
|
};
|
|
82
|
-
export declare function BookingWidgetPanel({ title, description, mobileHeader, mode, rescheduleContext, onRescheduleSubmit, successRedirectHref, __devForceState, __devForceSuccess, }: BookingWidgetProps): import("react/jsx-runtime").JSX.Element;
|
|
102
|
+
export declare function BookingWidgetPanel({ title, description, mobileHeader, mode, rescheduleContext, onRescheduleSubmit, initialSelection, successRedirectHref, __devForceState, __devForceSuccess, }: BookingWidgetProps): import("react/jsx-runtime").JSX.Element;
|
|
83
103
|
export {};
|
|
84
104
|
//# sourceMappingURL=booking-widget.d.ts.map
|
|
@@ -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;AAuDd;;;;;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,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;;;;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,mBAAyB,EACzB,eAAe,EACf,iBAAiB,GAClB,EAAE,kBAAkB,
|
|
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;AAuDd;;;;;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;CAC1C,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,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,mBAAyB,EACzB,eAAe,EACf,iBAAiB,GAClB,EAAE,kBAAkB,2CA0wFpB"}
|
package/dist/booking-widget.js
CHANGED
|
@@ -35,7 +35,7 @@ function findCalculatedServiceMissingIntake(setup) {
|
|
|
35
35
|
}
|
|
36
36
|
const MOBILE_PROGRESS_STEPS_SCHEDULED = [1, 2, 3, 4];
|
|
37
37
|
const MOBILE_PROGRESS_STEPS_ASYNC = [1, 3, 4];
|
|
38
|
-
export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
|
|
38
|
+
export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, initialSelection, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
|
|
39
39
|
const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
|
|
40
40
|
const isReschedule = mode === 'reschedule';
|
|
41
41
|
// Reschedule summary holds longer values (full former-time
|
|
@@ -62,8 +62,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
62
62
|
// appointment's service. We seed selection from rescheduleContext
|
|
63
63
|
// up front so the calendar/slot fetch fires without the user
|
|
64
64
|
// needing to click anything.
|
|
65
|
-
const [selectedServiceId, setSelectedServiceId] = useState(() => rescheduleContext?.serviceId ?? null);
|
|
66
|
-
const [selectedStaffId, setSelectedStaffId] = useState(() => rescheduleContext?.staffMemberId ??
|
|
65
|
+
const [selectedServiceId, setSelectedServiceId] = useState(() => rescheduleContext?.serviceId ?? initialSelection?.serviceId ?? null);
|
|
66
|
+
const [selectedStaffId, setSelectedStaffId] = useState(() => rescheduleContext?.staffMemberId ??
|
|
67
|
+
initialSelection?.staffMemberId ??
|
|
68
|
+
null);
|
|
67
69
|
const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
|
|
68
70
|
const [availabilityByDate, setAvailabilityByDate] = useState(new Map());
|
|
69
71
|
const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
|
|
@@ -82,12 +84,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
82
84
|
// Pre-fill contact fields from the original booking when
|
|
83
85
|
// rescheduling. Editable, since a user might want to fix typos at
|
|
84
86
|
// the same time, but the reschedule flow doesn't actually overwrite
|
|
85
|
-
// them
|
|
87
|
+
// them - the host's mutation only patches the time fields.
|
|
86
88
|
const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
|
|
87
89
|
const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
|
|
88
90
|
const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
|
|
89
91
|
const [customerNotes, setCustomerNotes] = useState('');
|
|
90
|
-
const [intakeResponses, setIntakeResponses] = useState({});
|
|
92
|
+
const [intakeResponses, setIntakeResponses] = useState(() => initialSelection?.intakeResponses ?? {});
|
|
93
|
+
const initialSelectionAppliedRef = useRef(false);
|
|
94
|
+
const previousSelectedServiceIdRef = useRef(selectedServiceId);
|
|
91
95
|
const [quote, setQuote] = useState(null);
|
|
92
96
|
const [isQuoteLoading, setIsQuoteLoading] = useState(false);
|
|
93
97
|
const [payment, setPayment] = useState(null);
|
|
@@ -122,6 +126,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
122
126
|
// cleanly. Avoids stale-memo / stale-ref edge cases that otherwise
|
|
123
127
|
// can leave the calendar empty until a hard refresh.
|
|
124
128
|
const [bookingFlowKey, setBookingFlowKey] = useState(0);
|
|
129
|
+
const initialServiceId = initialSelection?.serviceId;
|
|
130
|
+
const initialServiceSlug = initialSelection?.serviceSlug;
|
|
131
|
+
const initialStaffMemberId = initialSelection?.staffMemberId;
|
|
132
|
+
const initialCustomerNotes = initialSelection?.customerNotes;
|
|
133
|
+
const initialIntakeResponses = initialSelection?.intakeResponses;
|
|
125
134
|
const availabilityCacheRef = useRef(new Map());
|
|
126
135
|
const holdIdRef = useRef(null);
|
|
127
136
|
// Calendar card lives in the middle column and is the natural
|
|
@@ -177,9 +186,22 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
177
186
|
throw new Error(`Calculated service "${missingIntake.name}" is missing intakeForm.sections. Shipping services must provide an intakeForm instead of falling back to the generic contact form.`);
|
|
178
187
|
}
|
|
179
188
|
setSetup(result);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
189
|
+
if (!isReschedule && !initialSelectionAppliedRef.current) {
|
|
190
|
+
const initialService = (initialServiceId
|
|
191
|
+
? result.services.find((service) => service._id === initialServiceId)
|
|
192
|
+
: null) ??
|
|
193
|
+
(initialServiceSlug
|
|
194
|
+
? result.services.find((service) => service.slug === initialServiceSlug)
|
|
195
|
+
: null) ??
|
|
196
|
+
null;
|
|
197
|
+
if (initialService) {
|
|
198
|
+
setSelectedServiceId(initialService._id);
|
|
199
|
+
setSelectedStaffId(initialStaffMemberId ?? null);
|
|
200
|
+
setCustomerNotes(initialCustomerNotes ?? '');
|
|
201
|
+
setIntakeResponses(initialIntakeResponses ?? {});
|
|
202
|
+
initialSelectionAppliedRef.current = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
183
205
|
setError(null);
|
|
184
206
|
})
|
|
185
207
|
.catch((nextError) => {
|
|
@@ -197,7 +219,16 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
197
219
|
return () => {
|
|
198
220
|
cancelled = true;
|
|
199
221
|
};
|
|
200
|
-
}, [
|
|
222
|
+
}, [
|
|
223
|
+
client,
|
|
224
|
+
initialCustomerNotes,
|
|
225
|
+
initialIntakeResponses,
|
|
226
|
+
initialServiceId,
|
|
227
|
+
initialServiceSlug,
|
|
228
|
+
initialStaffMemberId,
|
|
229
|
+
isReschedule,
|
|
230
|
+
siteSlug,
|
|
231
|
+
]);
|
|
201
232
|
const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ??
|
|
202
233
|
null, [selectedServiceId, setup]);
|
|
203
234
|
const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
|
|
@@ -213,7 +244,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
213
244
|
const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
|
|
214
245
|
const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
|
|
215
246
|
// In reschedule mode we pre-select the original service but keep
|
|
216
|
-
// the full list visible
|
|
247
|
+
// the full list visible - the admin may legitimately need to
|
|
217
248
|
// switch service when rescheduling (e.g. customer asked for a
|
|
218
249
|
// shorter visit alongside the time change).
|
|
219
250
|
const visibleServices = useMemo(() => {
|
|
@@ -266,7 +297,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
266
297
|
// Original service / staff display names (looked up against the
|
|
267
298
|
// loaded setup), used in the reschedule summary's "Former …" rows.
|
|
268
299
|
// These render only when the user changes the corresponding field
|
|
269
|
-
// away from the original
|
|
300
|
+
// away from the original - picking only a new time leaves them
|
|
270
301
|
// hidden, so the summary stays clean for the common case.
|
|
271
302
|
const formerService = useMemo(() => {
|
|
272
303
|
if (!isReschedule || !rescheduleContext || !setup)
|
|
@@ -293,7 +324,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
293
324
|
return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
|
|
294
325
|
}, [selectedService, setup]);
|
|
295
326
|
useEffect(() => {
|
|
296
|
-
// Wait for availableStaff to populate before clearing
|
|
327
|
+
// Wait for availableStaff to populate before clearing - otherwise
|
|
297
328
|
// a pre-seeded staffMemberId (e.g. from rescheduleContext) gets
|
|
298
329
|
// wiped on the first render before the staff list arrives.
|
|
299
330
|
if (availableStaff.length === 0)
|
|
@@ -318,8 +349,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
318
349
|
// `availableStaffIds`, and we filter client-side per selected
|
|
319
350
|
// provider. That means switching providers in the dropdown is
|
|
320
351
|
// instant (no refetch) and we always know which staff are
|
|
321
|
-
// available on a given date
|
|
322
|
-
// selected
|
|
352
|
+
// available on a given date - even when a single provider is
|
|
353
|
+
// selected - so we can disable unavailable rows in the dropdown.
|
|
323
354
|
const requestKey = getAvailabilityCacheKey(siteSlug, selectedServiceId, calendarMonth);
|
|
324
355
|
const cachedAvailability = availabilityCacheRef.current.get(requestKey);
|
|
325
356
|
if (cachedAvailability) {
|
|
@@ -431,7 +462,16 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
431
462
|
selectedStaffId,
|
|
432
463
|
]);
|
|
433
464
|
useEffect(() => {
|
|
434
|
-
|
|
465
|
+
if (previousSelectedServiceIdRef.current === selectedServiceId) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
previousSelectedServiceIdRef.current = selectedServiceId;
|
|
469
|
+
if (initialSelectionAppliedRef.current) {
|
|
470
|
+
initialSelectionAppliedRef.current = false;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
setIntakeResponses({});
|
|
474
|
+
}
|
|
435
475
|
setQuote(null);
|
|
436
476
|
}, [selectedServiceId]);
|
|
437
477
|
useEffect(() => {
|
|
@@ -549,7 +589,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
549
589
|
});
|
|
550
590
|
}, refreshAt);
|
|
551
591
|
return () => window.clearTimeout(handle);
|
|
552
|
-
// handleHoldExpired is stable enough
|
|
592
|
+
// handleHoldExpired is stable enough - it only reads setters
|
|
553
593
|
}, [client, holdExpiresAt, holdId, sessionToken, siteSlug]);
|
|
554
594
|
// Fires the moment the displayed countdown hits zero. The refresh
|
|
555
595
|
// effect above usually preempts this by extending the hold ~30s
|
|
@@ -632,7 +672,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
632
672
|
// Mobile 4-step gates. canAdvanceStepN = "the user can move PAST
|
|
633
673
|
// step N to step N+1". Step 1 (service) requires a pick. Step 2
|
|
634
674
|
// combines calendar + provider + time slots; user must pick date +
|
|
635
|
-
// slot to advance. Step 3 is the details form
|
|
675
|
+
// slot to advance. Step 3 is the details form - needs contact name,
|
|
636
676
|
// valid email, and any required intake fields filled. Step 4 is
|
|
637
677
|
// the review screen (submit, not advance).
|
|
638
678
|
const canAdvanceStep1 = Boolean(selectedService);
|
|
@@ -650,10 +690,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
650
690
|
// Re-mount the slots wrapper whenever the inputs that affect the
|
|
651
691
|
// slot list change so the CSS enter animation fires (.bw-slots-fade
|
|
652
692
|
// + per-slot stagger). React diffs keys per parent, so reusing the
|
|
653
|
-
// same key on both the mobile and desktop render sites is fine
|
|
693
|
+
// same key on both the mobile and desktop render sites is fine -
|
|
654
694
|
// each parent independently remounts its child.
|
|
655
695
|
// Provider row is the visible-list replacement for the old
|
|
656
|
-
// dropdown
|
|
696
|
+
// dropdown - same data, same handlers, but always-rendered so the
|
|
657
697
|
// user sees every option at a glance (Any + each staff). Active
|
|
658
698
|
// selection is the filled state; unavailable staff (no slots on
|
|
659
699
|
// the selected date) render disabled with an inline "Unavailable"
|
|
@@ -736,7 +776,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
736
776
|
const intakeFields = renderIntakeFields(idPrefix);
|
|
737
777
|
const contactFields = renderContactFields(idSuffix);
|
|
738
778
|
// Contact first, intake after. Customers expect "tell us who you
|
|
739
|
-
// are" before "tell us about your shipment"
|
|
779
|
+
// are" before "tell us about your shipment" - name and email are
|
|
740
780
|
// identity, intake fields are service-specific details.
|
|
741
781
|
return (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
|
|
742
782
|
}
|
|
@@ -858,7 +898,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
858
898
|
function handleConfirmAttempt() {
|
|
859
899
|
// The disabled state is purely visual (aria-disabled + class). Real
|
|
860
900
|
// submittability gates here so we can surface validation help only
|
|
861
|
-
// after the user actually tries to confirm
|
|
901
|
+
// after the user actually tries to confirm - never on a fresh form.
|
|
862
902
|
if (!canSubmit || isQuoteLoading || isSubmitting) {
|
|
863
903
|
setSubmitAttempted(true);
|
|
864
904
|
return;
|
|
@@ -995,7 +1035,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
995
1035
|
}
|
|
996
1036
|
if (forcedState === 'cancelled') {
|
|
997
1037
|
return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon bw-done-icon--muted", children: _jsx(XIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: t('cancelledTitle') }), _jsx("p", { className: "bw-done-text", children: t('cancelledBody') }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
|
|
998
|
-
/* dev preview only
|
|
1038
|
+
/* dev preview only - host wires the real handler */
|
|
999
1039
|
}, children: t('cancelledCta') })] }) }));
|
|
1000
1040
|
}
|
|
1001
1041
|
if (success || forcedState === 'success') {
|
|
@@ -1249,7 +1289,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1249
1289
|
? t('btnContinueToPayment')
|
|
1250
1290
|
: t('btnConfirmBooking') }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] }, bookingFlowKey) }));
|
|
1251
1291
|
}
|
|
1252
|
-
/* Payment panel
|
|
1292
|
+
/* Payment panel - design proposal for the pay-before-booking flow.
|
|
1253
1293
|
* Renders inside the desktop details form (and mobile step-4 form
|
|
1254
1294
|
* once wired) when the selected service has paymentMode === 'full'
|
|
1255
1295
|
* or 'deposit'. Visuals only today: real implementation will mount
|
|
@@ -1264,7 +1304,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1264
1304
|
* 1. Move/replace this PaymentPanel into the left summary pane
|
|
1265
1305
|
* (.bw-details-summary) so the Stripe Element sits where the
|
|
1266
1306
|
* customer reads what they're paying for. The right form pane
|
|
1267
|
-
* stays as-is
|
|
1307
|
+
* stays as-is - name/email/phone/notes only.
|
|
1268
1308
|
* 2. Backend gap: getPublicBookingSetup currently strips the
|
|
1269
1309
|
* payment fields. Surface paymentMode, depositCents,
|
|
1270
1310
|
* requiresPayment, and the workspace's Stripe Connect account
|
|
@@ -1276,7 +1316,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
|
|
|
1276
1316
|
* to the bookingHoldId so the amount can't be tampered with).
|
|
1277
1317
|
* 4. Gate createPublicAppointment behind paymentIntent.status
|
|
1278
1318
|
* === 'succeeded'. On failure: keep the hold for one retry.
|
|
1279
|
-
* 5. Confirm CTA copy already branches on forcedState
|
|
1319
|
+
* 5. Confirm CTA copy already branches on forcedState - wire the
|
|
1280
1320
|
* same branch on the real `service.requiresPayment` field.
|
|
1281
1321
|
* 6. Visual reference: this PaymentPanel + the dev preview pills
|
|
1282
1322
|
* ('Pay full' / 'Pay deposit') in dev/booking-widget-preview
|
|
@@ -1505,12 +1545,12 @@ function IntakeSelect({ id, value, onChange, options, placeholder, required, })
|
|
|
1505
1545
|
* Decide which side to open the menu on. If the space below the
|
|
1506
1546
|
* trigger isn't enough to fit the menu, and there's more space
|
|
1507
1547
|
* above, flip the menu above the trigger. Runs at every open
|
|
1508
|
-
* (desktop AND mobile)
|
|
1548
|
+
* (desktop AND mobile) - handles both the desktop near-footer
|
|
1509
1549
|
* case and the mobile near-bottom-of-widget case.
|
|
1510
1550
|
*
|
|
1511
1551
|
* The menu's CSS max-height is `min(280px, 60svh)`. We compute
|
|
1512
1552
|
* the same effective max here so the flip decision tracks the
|
|
1513
|
-
* real available space
|
|
1553
|
+
* real available space - important on short phones where 60svh
|
|
1514
1554
|
* is much smaller than 280px.
|
|
1515
1555
|
*/
|
|
1516
1556
|
function computePlacement() {
|
|
@@ -1770,7 +1810,7 @@ function readString(value) {
|
|
|
1770
1810
|
// SSR/no-bones space holder and Suspense fallback for host sites.
|
|
1771
1811
|
function AvailabilitySkeleton() {
|
|
1772
1812
|
// Reuses .bw-time-slots so the skeleton inherits the same layout
|
|
1773
|
-
// overrides as the real slots
|
|
1813
|
+
// overrides as the real slots - vertical 1-col on desktop (via the
|
|
1774
1814
|
// .bw-slots-desktop scope), 4-col grid on mobile (default). Pill
|
|
1775
1815
|
// dimensions match the rendered .bw-slot.
|
|
1776
1816
|
return (_jsx("div", { className: "bw-time-slots bw-time-slots--skeleton", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }));
|
|
@@ -1867,7 +1907,7 @@ function formatDateKey(date) {
|
|
|
1867
1907
|
function formatMonthLabel(date, locale = 'en-US') {
|
|
1868
1908
|
// Use a simple "Month YYYY" shape across locales. Default Spanish
|
|
1869
1909
|
// formatting yields "mayo de 2026" (lowercased, with "de"), which
|
|
1870
|
-
// we don't want
|
|
1910
|
+
// we don't want - strip both for a tighter calendar header.
|
|
1871
1911
|
const month = new Intl.DateTimeFormat(locale, { month: 'long' }).format(date);
|
|
1872
1912
|
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
|
|
1873
1913
|
return `${capitalizedMonth} ${date.getFullYear()}`;
|