@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 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 your toggle component flips both the host site's text and the widget by sharing the same `language` state.
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** 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.)
185
- - **Customer-typed input** names, notes, etc.
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** pass a single reactive `language` value into `SableSiteConfig` and the widget stays in sync automatically.
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
 
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- // Auto-generated by `npx boneyard-js build` do not edit
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';
@@ -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 cannot be changed without
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 every existing consumer that omits
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 these never call any
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 the customer-side cancel mutation isn't built yet).
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,2CA6tFpB"}
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"}
@@ -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 ?? null);
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 the host's mutation only patches the time fields.
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
- // Don't auto-select a service in create mode — let the user
181
- // actively pick one. Reschedule mode pre-seeds via the
182
- // useState initializer above so this preserves that.
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
- }, [client, siteSlug]);
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 the admin may legitimately need to
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 picking only a new time leaves them
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 otherwise
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 even when a single provider is
322
- // selected so we can disable unavailable rows in the dropdown.
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
- setIntakeResponses({});
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 it only reads setters
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 needs contact name,
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 same data, same handlers, but always-rendered so the
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" name and email are
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 never on a fresh form.
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 host wires the real handler */
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 design proposal for the pay-before-booking flow.
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 name/email/phone/notes only.
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 wire the
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) handles both the desktop near-footer
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 important on short phones where 60svh
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 vertical 1-col on desktop (via the
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 strip both for a tighter calendar header.
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()}`;