@asksable/site-connector 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,37 +1,135 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { loadStripe } from '@stripe/stripe-js';
5
+ import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
3
6
  import { useSableSiteClient, useSableSiteConfig } from './provider.js';
7
+ const stripePromises = new Map();
8
+ function getStripePromise(publishableKey, connectAccountId) {
9
+ const key = `${publishableKey}:${connectAccountId}`;
10
+ const cached = stripePromises.get(key);
11
+ if (cached)
12
+ return cached;
13
+ const promise = loadStripe(publishableKey, { stripeAccount: connectAccountId });
14
+ stripePromises.set(key, promise);
15
+ return promise;
16
+ }
17
+ function isDevRuntime() {
18
+ const env = import.meta.env;
19
+ return env?.DEV === true || env?.MODE === 'development';
20
+ }
21
+ function hasIntakeFormSections(service) {
22
+ return (service?.intakeForm?.sections?.length ?? 0) > 0;
23
+ }
24
+ function findCalculatedServiceMissingIntake(setup) {
25
+ return setup.services.find((service) => service.pricingMode === 'calculated' && !hasIntakeFormSections(service));
26
+ }
4
27
  const MOBILE_PROGRESS_STEPS = [1, 2, 3, 4];
5
- export function BookingWidgetPanel({ title, description, mobileHeader, }) {
28
+ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'create', rescheduleContext, onRescheduleSubmit, successRedirectHref = '/', __devForceState, __devForceSuccess, }) {
29
+ const forcedState = __devForceState ?? (__devForceSuccess ? 'success' : null);
30
+ const isReschedule = mode === 'reschedule';
31
+ // Reschedule summary holds longer values (full former-time
32
+ // sentence, "Signature Color Refresh") that the widget's default
33
+ // single-line ellipsis truncates. We override per-row inline so
34
+ // create mode is unchanged.
35
+ const summaryValStyle = isReschedule
36
+ ? {
37
+ whiteSpace: 'normal',
38
+ overflow: 'visible',
39
+ textOverflow: 'clip',
40
+ wordBreak: 'normal',
41
+ overflowWrap: 'anywhere',
42
+ }
43
+ : undefined;
6
44
  const client = useSableSiteClient();
7
45
  const { siteSlug } = useSableSiteConfig();
8
46
  const [setup, setSetup] = useState(null);
9
47
  const [isSetupLoading, setIsSetupLoading] = useState(true);
10
- const [selectedServiceId, setSelectedServiceId] = useState(null);
11
- const [selectedStaffId, setSelectedStaffId] = useState(null);
48
+ // In reschedule mode the service is locked to the original
49
+ // appointment's service. We seed selection from rescheduleContext
50
+ // up front so the calendar/slot fetch fires without the user
51
+ // needing to click anything.
52
+ const [selectedServiceId, setSelectedServiceId] = useState(() => rescheduleContext?.serviceId ?? null);
53
+ const [selectedStaffId, setSelectedStaffId] = useState(() => rescheduleContext?.staffMemberId ?? null);
12
54
  const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
13
55
  const [availabilityByDate, setAvailabilityByDate] = useState(new Map());
14
56
  const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
15
57
  const [selectedDate, setSelectedDate] = useState(null);
16
58
  const [selectedSlot, setSelectedSlot] = useState(null);
17
59
  const [pendingSlotKey, setPendingSlotKey] = useState(null);
60
+ // Service picker is progressive: full list shows initially, collapses
61
+ // to a single "selected service" card once a service is picked, then
62
+ // the provider section reveals below. Click Change on the card flips
63
+ // back to full list. Reschedule mode skips the collapse since the
64
+ // service is locked from rescheduleContext at mount.
65
+ const [isEditingService, setIsEditingService] = useState(false);
18
66
  const [holdId, setHoldId] = useState(null);
19
67
  const [holdExpiresAt, setHoldExpiresAt] = useState(null);
20
68
  const [heldStaffId, setHeldStaffId] = useState(null);
21
- const [customerName, setCustomerName] = useState('');
22
- const [customerEmail, setCustomerEmail] = useState('');
23
- const [customerPhone, setCustomerPhone] = useState('');
69
+ // Pre-fill contact fields from the original booking when
70
+ // rescheduling. Editable, since a user might want to fix typos at
71
+ // the same time, but the reschedule flow doesn't actually overwrite
72
+ // them — the host's mutation only patches the time fields.
73
+ const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
74
+ const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
75
+ const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
24
76
  const [customerNotes, setCustomerNotes] = useState('');
77
+ const [intakeResponses, setIntakeResponses] = useState({});
78
+ const [quote, setQuote] = useState(null);
79
+ const [isQuoteLoading, setIsQuoteLoading] = useState(false);
80
+ const [payment, setPayment] = useState(null);
81
+ const [paymentAppointmentId, setPaymentAppointmentId] = useState(null);
25
82
  const [isSubmitting, setIsSubmitting] = useState(false);
26
83
  const [error, setError] = useState(null);
27
84
  const [success, setSuccess] = useState(null);
28
85
  const [mobileStep, setMobileStep] = useState(1);
86
+ const [mobilePaymentActionTarget, setMobilePaymentActionTarget] = useState(null);
87
+ // Two-step desktop flow: 'slots' shows the available-times list,
88
+ // 'details' shows the customer form + summary + confirm. Picking a
89
+ // slot auto-advances to details with a crossfade matching Sable's
90
+ // popup rule (250ms expo ease-out in, 150ms ease-out out). Back
91
+ // button in details returns to slots and releases the hold.
92
+ const [viewState, setViewState] = useState('slots');
93
+ // Dev-only: force viewState to 'details' when previewing a payment
94
+ // variant, since the payment panel renders inside the details form.
95
+ useEffect(() => {
96
+ if (__devForceState === 'payment-full' || __devForceState === 'payment-deposit') {
97
+ setViewState('details');
98
+ }
99
+ }, [__devForceState]);
29
100
  const [monthOpen, setMonthOpen] = useState(false);
30
101
  const [staffOpen, setStaffOpen] = useState(false);
31
102
  const [holdNow, setHoldNow] = useState(() => Date.now());
32
103
  const [sessionToken] = useState(() => sessionTokenFromBrowser());
33
104
  const staffMenuRef = useRef(null);
34
105
  const availabilityCacheRef = useRef(new Map());
106
+ const holdIdRef = useRef(null);
107
+ // Calendar card lives in the middle column and is the natural
108
+ // height anchor of the desktop layout. We measure it on resize and
109
+ // expose --bw-cal-h on the .bw root so the left (services) and
110
+ // right (slots) columns can cap their internal scrollers to match,
111
+ // keeping all three columns the same visual height regardless of
112
+ // service-list length or slot count.
113
+ const calCardRef = useRef(null);
114
+ const widgetRootRef = useRef(null);
115
+ useEffect(() => {
116
+ const card = calCardRef.current;
117
+ const root = widgetRootRef.current;
118
+ if (!card || !root)
119
+ return;
120
+ const observer = new ResizeObserver((entries) => {
121
+ const entry = entries[0];
122
+ if (!entry)
123
+ return;
124
+ const height = Math.round(entry.contentRect.height);
125
+ root.style.setProperty('--bw-cal-h', `${height}px`);
126
+ });
127
+ observer.observe(card);
128
+ return () => observer.disconnect();
129
+ }, [setup]);
130
+ useEffect(() => {
131
+ holdIdRef.current = holdId;
132
+ }, [holdId]);
35
133
  useEffect(() => {
36
134
  let cancelled = false;
37
135
  setIsSetupLoading(true);
@@ -41,8 +139,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
41
139
  if (cancelled) {
42
140
  return;
43
141
  }
142
+ const missingIntake = findCalculatedServiceMissingIntake(result);
143
+ if (missingIntake && isDevRuntime()) {
144
+ 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.`);
145
+ }
44
146
  setSetup(result);
45
- setSelectedServiceId((current) => current || result.services[0]?._id || null);
147
+ // Don't auto-select a service in create mode — let the user
148
+ // actively pick one. Reschedule mode pre-seeds via the
149
+ // useState initializer above so this preserves that.
46
150
  setError(null);
47
151
  })
48
152
  .catch((nextError) => {
@@ -73,6 +177,90 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
73
177
  };
74
178
  }, [staffOpen]);
75
179
  const selectedService = useMemo(() => setup?.services.find((service) => service._id === selectedServiceId) ?? null, [selectedServiceId, setup]);
180
+ const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
181
+ const selectedServicePath = selectedServiceRequiresSlot ? 'scheduled' : 'async';
182
+ const selectedServiceHasIntakeForm = hasIntakeFormSections(selectedService);
183
+ const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
184
+ areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
185
+ const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath), [selectedService, selectedServicePath]);
186
+ const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
187
+ const trimmedCustomerEmail = customerEmail.trim();
188
+ const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
189
+ const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
190
+ // In reschedule mode we pre-select the original service but keep
191
+ // the full list visible — the admin may legitimately need to
192
+ // switch service when rescheduling (e.g. customer asked for a
193
+ // shorter visit alongside the time change).
194
+ const visibleServices = useMemo(() => {
195
+ if (!setup)
196
+ return [];
197
+ return setup.services;
198
+ }, [setup]);
199
+ const serviceGroups = useMemo(() => {
200
+ if (!setup || visibleServices.length === 0)
201
+ return [];
202
+ const categoriesById = new Map((setup.categories ?? []).map((c) => [c._id, c]));
203
+ const groups = new Map();
204
+ const uncategorizedKey = '__uncategorized__';
205
+ for (const service of visibleServices) {
206
+ const category = service.categoryId != null
207
+ ? categoriesById.get(service.categoryId) ?? null
208
+ : null;
209
+ const key = category?._id ?? uncategorizedKey;
210
+ const existing = groups.get(key);
211
+ if (existing) {
212
+ existing.services.push(service);
213
+ }
214
+ else {
215
+ groups.set(key, {
216
+ key,
217
+ categoryName: category?.name ?? null,
218
+ services: [service],
219
+ });
220
+ }
221
+ }
222
+ // Order: categories by their configured sortOrder, then any
223
+ // uncategorized bucket at the end.
224
+ const orderedCategoryKeys = (setup.categories ?? []).map((c) => c._id);
225
+ const ordered = [];
226
+ for (const id of orderedCategoryKeys) {
227
+ const g = groups.get(id);
228
+ if (g)
229
+ ordered.push(g);
230
+ }
231
+ const uncategorized = groups.get(uncategorizedKey);
232
+ if (uncategorized)
233
+ ordered.push(uncategorized);
234
+ // If the workspace has no categories at all, return a single
235
+ // group with no header so the picker still reads as one list.
236
+ if (ordered.length === 1 && ordered[0].categoryName === null) {
237
+ return ordered;
238
+ }
239
+ return ordered;
240
+ }, [setup, visibleServices]);
241
+ // Original service / staff display names (looked up against the
242
+ // loaded setup), used in the reschedule summary's "Former …" rows.
243
+ // These render only when the user changes the corresponding field
244
+ // away from the original — picking only a new time leaves them
245
+ // hidden, so the summary stays clean for the common case.
246
+ const formerService = useMemo(() => {
247
+ if (!isReschedule || !rescheduleContext || !setup)
248
+ return null;
249
+ return (setup.services.find((s) => s._id === rescheduleContext.serviceId) ?? null);
250
+ }, [isReschedule, rescheduleContext, setup]);
251
+ const formerStaffName = useMemo(() => {
252
+ if (!isReschedule || !rescheduleContext?.staffMemberId || !setup) {
253
+ return null;
254
+ }
255
+ return (setup.staff.find((s) => s._id === rescheduleContext.staffMemberId)
256
+ ?.name ?? null);
257
+ }, [isReschedule, rescheduleContext, setup]);
258
+ const serviceChanged = isReschedule &&
259
+ rescheduleContext != null &&
260
+ selectedServiceId !== rescheduleContext.serviceId;
261
+ const staffChanged = isReschedule &&
262
+ rescheduleContext != null &&
263
+ (selectedStaffId ?? null) !== (rescheduleContext.staffMemberId ?? null);
76
264
  const availableStaff = useMemo(() => {
77
265
  if (!setup || !selectedService) {
78
266
  return [];
@@ -80,13 +268,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
80
268
  return setup.staff.filter((staffMember) => selectedService.assignedStaffIds.includes(staffMember._id));
81
269
  }, [selectedService, setup]);
82
270
  useEffect(() => {
271
+ // Wait for availableStaff to populate before clearing — otherwise
272
+ // a pre-seeded staffMemberId (e.g. from rescheduleContext) gets
273
+ // wiped on the first render before the staff list arrives.
274
+ if (availableStaff.length === 0)
275
+ return;
83
276
  if (selectedStaffId && !availableStaff.some((staffMember) => staffMember._id === selectedStaffId)) {
84
277
  setSelectedStaffId(null);
85
278
  }
86
279
  }, [availableStaff, selectedStaffId]);
87
280
  const selectedStaff = useMemo(() => availableStaff.find((staffMember) => staffMember._id === selectedStaffId) ?? null, [availableStaff, selectedStaffId]);
88
281
  useEffect(() => {
89
- if (!selectedServiceId) {
282
+ if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
90
283
  setAvailabilityByDate(new Map());
91
284
  setSelectedDate(null);
92
285
  return;
@@ -150,9 +343,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
150
343
  return () => {
151
344
  cancelled = true;
152
345
  };
153
- }, [calendarMonth, client, selectedDate, selectedServiceId, selectedStaffId, siteSlug]);
346
+ }, [calendarMonth, client, selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
154
347
  useEffect(() => {
155
- if (!selectedServiceId) {
348
+ if (!selectedServiceId || selectedService?.publicBookingMode === 'async') {
156
349
  return;
157
350
  }
158
351
  const nextMonth = addMonths(calendarMonth, 1);
@@ -164,14 +357,65 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
164
357
  calendarMonth: nextMonth,
165
358
  cacheRef: availabilityCacheRef,
166
359
  });
167
- }, [calendarMonth, client, selectedServiceId, selectedStaffId, siteSlug]);
360
+ }, [calendarMonth, client, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId, siteSlug]);
168
361
  useEffect(() => {
169
362
  setSelectedSlot(null);
170
363
  setPendingSlotKey(null);
171
364
  setHeldStaffId(null);
172
365
  setSuccess(null);
173
366
  setError(null);
174
- }, [selectedDate, selectedServiceId, selectedStaffId]);
367
+ setPayment(null);
368
+ setPaymentAppointmentId(null);
369
+ if (selectedService?.publicBookingMode === 'async') {
370
+ setViewState('details');
371
+ setMobileStep(4);
372
+ }
373
+ else {
374
+ setViewState('slots');
375
+ }
376
+ }, [selectedDate, selectedService?.publicBookingMode, selectedServiceId, selectedStaffId]);
377
+ useEffect(() => {
378
+ setIntakeResponses({});
379
+ setQuote(null);
380
+ }, [selectedServiceId]);
381
+ useEffect(() => {
382
+ if (!selectedServiceId ||
383
+ selectedService?.pricingMode !== 'calculated' ||
384
+ !isIntakeComplete) {
385
+ setQuote(null);
386
+ setIsQuoteLoading(false);
387
+ return;
388
+ }
389
+ let cancelled = false;
390
+ setIsQuoteLoading(true);
391
+ const handle = window.setTimeout(() => {
392
+ void client
393
+ .quotePublicBooking({
394
+ siteSlug,
395
+ serviceId: selectedServiceId,
396
+ intakeResponses,
397
+ })
398
+ .then((nextQuote) => {
399
+ if (!cancelled) {
400
+ setQuote(nextQuote);
401
+ setError(null);
402
+ }
403
+ })
404
+ .catch((nextError) => {
405
+ if (!cancelled) {
406
+ setError(nextError instanceof Error ? nextError.message : 'Unable to calculate price.');
407
+ }
408
+ })
409
+ .finally(() => {
410
+ if (!cancelled)
411
+ setIsQuoteLoading(false);
412
+ });
413
+ }, 300);
414
+ return () => {
415
+ cancelled = true;
416
+ window.clearTimeout(handle);
417
+ };
418
+ }, [client, intakeResponses, isIntakeComplete, selectedService?.pricingMode, selectedServiceId, siteSlug]);
175
419
  useEffect(() => {
176
420
  return () => {
177
421
  if (!holdId) {
@@ -186,6 +430,36 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
186
430
  .catch(() => undefined);
187
431
  };
188
432
  }, [client, holdId, sessionToken, siteSlug]);
433
+ useEffect(() => {
434
+ function releaseHoldForPageLeave() {
435
+ const activeHoldId = holdIdRef.current;
436
+ if (!activeHoldId) {
437
+ return;
438
+ }
439
+ client.releasePublicBookingHoldOnPageLeave({
440
+ siteSlug,
441
+ holdId: activeHoldId,
442
+ sessionToken,
443
+ });
444
+ holdIdRef.current = null;
445
+ setHoldId(null);
446
+ setHoldExpiresAt(null);
447
+ setHeldStaffId(null);
448
+ }
449
+ function handleVisibilityChange() {
450
+ if (document.visibilityState === 'hidden') {
451
+ releaseHoldForPageLeave();
452
+ }
453
+ }
454
+ window.addEventListener('pagehide', releaseHoldForPageLeave);
455
+ window.addEventListener('beforeunload', releaseHoldForPageLeave);
456
+ document.addEventListener('visibilitychange', handleVisibilityChange);
457
+ return () => {
458
+ window.removeEventListener('pagehide', releaseHoldForPageLeave);
459
+ window.removeEventListener('beforeunload', releaseHoldForPageLeave);
460
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
461
+ };
462
+ }, [client, sessionToken, siteSlug]);
189
463
  useEffect(() => {
190
464
  if (!holdId || !holdExpiresAt) {
191
465
  return;
@@ -230,11 +504,103 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
230
504
  const holdSecondsRemaining = holdExpiresAt !== null ? Math.max(0, Math.floor((holdExpiresAt - holdNow) / 1000)) : null;
231
505
  const monthOptions = useMemo(() => getMonthOptions(calendarMonth, 4), [calendarMonth]);
232
506
  const calendarDays = useMemo(() => buildCalendarDays(calendarMonth), [calendarMonth]);
233
- const canAdvanceStep1 = true;
234
- const canAdvanceStep2 = Boolean(selectedService);
507
+ // Mobile 4-step gates. canAdvanceStepN = "the user can move PAST
508
+ // step N to step N+1". Step 1 (service) requires a pick. Step 2
509
+ // (provider) is always passable since "Any provider" is the
510
+ // default. Step 3 (calendar) requires both date + slot.
511
+ const canAdvanceStep1 = Boolean(selectedService);
512
+ const canAdvanceStep2 = true;
235
513
  const canAdvanceStep3 = Boolean(selectedDate && selectedSlot);
236
- const canSubmit = Boolean(selectedService && selectedDate && selectedSlot && holdId && customerName.trim() && customerEmail.trim()) &&
237
- !isSubmitting;
514
+ // Slots block is rendered in two locations: inline under the calendar
515
+ // (mobile flow keeps current step-3 behavior) and at the top of the
516
+ // right column on desktop (calendar | slots | form layout). Both
517
+ // locations share the same handlers and state, so picking a slot in
518
+ // either place updates the widget identically. CSS hides the wrong
519
+ // copy per breakpoint via .bw-slots-mobile / .bw-slots-desktop.
520
+ // Re-mount the slots wrapper whenever the inputs that affect the
521
+ // slot list change so the CSS enter animation fires (.bw-slots-fade
522
+ // + per-slot stagger). React diffs keys per parent, so reusing the
523
+ // same key on both the mobile and desktop render sites is fine —
524
+ // each parent independently remounts its child.
525
+ const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
526
+ const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: "Please select a service." })) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
527
+ const slotKey = `${slot.time}-${slot.endTime}`;
528
+ const isPending = pendingSlotKey === slotKey;
529
+ const isActive = isPending ||
530
+ (selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
531
+ return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, style: { '--bw-slot-i': index }, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? 'Securing...' : formatTimeLabel(slot.time) }, slotKey));
532
+ }) })) : (_jsx("p", { className: "bw-no-slots", children: "No available times for this date." }))) : null }, slotsAreaKey));
533
+ const canSubmit = Boolean(selectedService &&
534
+ (!selectedServiceRequiresSlot || (selectedDate && selectedSlot && holdId)) &&
535
+ isIntakeComplete &&
536
+ (selectedService.pricingMode !== 'calculated' || quote) &&
537
+ customerName.trim() &&
538
+ isCustomerEmailValid) && !isSubmitting;
539
+ const submitBlockers = selectedService
540
+ ? getSubmitBlockers({
541
+ selectedService,
542
+ selectedServiceRequiresSlot,
543
+ selectedDate,
544
+ selectedSlot,
545
+ holdId,
546
+ isIntakeComplete,
547
+ intakeResponses,
548
+ selectedServicePath,
549
+ quote,
550
+ customerName,
551
+ customerEmail,
552
+ })
553
+ : [];
554
+ function handleNotesInput(event) {
555
+ setCustomerNotes(event.currentTarget.value);
556
+ }
557
+ function renderNotesField(id, label, placeholder) {
558
+ return (_jsxs("div", { className: "bw-field bw-field--notes", children: [_jsx("label", { htmlFor: id, children: label }), _jsx("textarea", { id: id, rows: 3, maxLength: 500, value: customerNotes, onChange: handleNotesInput, onInput: handleNotesInput, placeholder: placeholder }), _jsxs("span", { className: "bw-char-count", "aria-live": "polite", children: [customerNotes.length, "/500"] })] }));
559
+ }
560
+ function renderSubmitHelp() {
561
+ if (!selectedService || canSubmit || payment || isSubmitting)
562
+ return null;
563
+ if (submitBlockers.length === 0 && !isQuoteLoading)
564
+ return null;
565
+ const visibleBlockers = submitBlockers.slice(0, 5);
566
+ const hiddenCount = submitBlockers.length - visibleBlockers.length;
567
+ return (_jsxs("div", { className: "bw-submit-help", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bw-submit-help-title", children: isQuoteLoading ? 'Calculating your price...' : 'Complete required fields to continue:' }), !isQuoteLoading && visibleBlockers.length > 0 ? (_jsxs("ul", { children: [visibleBlockers.map((blocker) => (_jsx("li", { children: blocker }, blocker))), hiddenCount > 0 ? _jsxs("li", { children: [hiddenCount, " more required fields"] }) : null] })) : null] }));
568
+ }
569
+ function renderContactFields(idSuffix) {
570
+ const nameId = `bw-name${idSuffix}`;
571
+ const emailId = `bw-email${idSuffix}`;
572
+ const phoneId = `bw-phone${idSuffix}`;
573
+ const emailErrorId = `${emailId}-error`;
574
+ return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: ["Full name ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "John Doe" })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: ["Email ", _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: "jane@example.com" }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: "Enter a valid email address." })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: "Phone" }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "+1 (555) 000-0000" })] })] }));
575
+ }
576
+ function renderIntakeFields(idPrefix) {
577
+ return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
578
+ }
579
+ function renderIntakeAndContact(idPrefix, idSuffix) {
580
+ const intakeFields = renderIntakeFields(idPrefix);
581
+ const contactFields = renderContactFields(idSuffix);
582
+ return selectedServiceHasIntakeForm ? (_jsxs(_Fragment, { children: [intakeFields, contactFields] })) : (_jsxs(_Fragment, { children: [contactFields, intakeFields] }));
583
+ }
584
+ function handleServiceSelect(service) {
585
+ if (holdId) {
586
+ void client
587
+ .releasePublicBookingHold({ siteSlug, holdId, sessionToken })
588
+ .catch(() => undefined);
589
+ }
590
+ setSelectedServiceId(service._id);
591
+ setIsEditingService(false);
592
+ setPayment(null);
593
+ setPaymentAppointmentId(null);
594
+ if (service.publicBookingMode === 'async') {
595
+ setSelectedDate(null);
596
+ setSelectedSlot(null);
597
+ setHoldId(null);
598
+ setHoldExpiresAt(null);
599
+ setHeldStaffId(null);
600
+ setViewState('details');
601
+ setMobileStep(4);
602
+ }
603
+ }
238
604
  async function handleSlotSelect(slot) {
239
605
  if (!selectedServiceId || !selectedDate) {
240
606
  return;
@@ -279,6 +645,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
279
645
  setHoldExpiresAt(result.expiresAt);
280
646
  setHeldStaffId(reservationStaffId);
281
647
  setError(null);
648
+ // Auto-advance the desktop right pane to the details/form view
649
+ // once the hold is reserved. Mobile flow keeps the existing
650
+ // step-based navigation; this state change is harmless there
651
+ // because mobile CSS forces the details pane visible regardless.
652
+ setViewState('details');
282
653
  }
283
654
  catch (nextError) {
284
655
  setSelectedSlot(previousSlot);
@@ -289,28 +660,96 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
289
660
  setError(nextError instanceof Error ? nextError.message : 'Unable to reserve that time.');
290
661
  }
291
662
  }
663
+ async function handleBackToSlots() {
664
+ if (holdId) {
665
+ await client
666
+ .releasePublicBookingHold({ siteSlug, holdId, sessionToken })
667
+ .catch(() => undefined);
668
+ }
669
+ setSelectedSlot(null);
670
+ setHoldId(null);
671
+ setHoldExpiresAt(null);
672
+ setHeldStaffId(null);
673
+ setPendingSlotKey(null);
674
+ setViewState('slots');
675
+ }
676
+ function handleBackToServicePicker() {
677
+ setSelectedServiceId(null);
678
+ setSelectedDate(null);
679
+ setSelectedSlot(null);
680
+ setHoldId(null);
681
+ setHoldExpiresAt(null);
682
+ setHeldStaffId(null);
683
+ setPendingSlotKey(null);
684
+ setIsEditingService(false);
685
+ setPayment(null);
686
+ setPaymentAppointmentId(null);
687
+ setViewState('slots');
688
+ setMobileStep(1);
689
+ }
292
690
  async function handleConfirmBooking() {
293
- if (!selectedServiceId || !selectedService || !selectedDate || !selectedSlot || !holdId) {
691
+ if (!selectedServiceId ||
692
+ !selectedService ||
693
+ (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId))) {
294
694
  return;
295
695
  }
296
696
  setIsSubmitting(true);
297
697
  setError(null);
698
+ const newStartTime = selectedDate && selectedSlot
699
+ ? Date.parse(`${selectedDate}T${selectedSlot.time}:00`)
700
+ : Date.now();
701
+ const newEndTime = selectedDate && selectedSlot
702
+ ? Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`)
703
+ : newStartTime + selectedService.durationMinutes * 60 * 1000;
704
+ // Reschedule mode bypasses the public-create flow entirely.
705
+ // Service / staff / customer are already attached to the
706
+ // original appointment, so the host (admin path) or magic-link
707
+ // signed customer (public path) only needs to dispatch the new
708
+ // start/end. The hold is released as a courtesy since the slot
709
+ // is being committed via a different mutation that re-runs the
710
+ // conflict check itself.
711
+ if (isReschedule && onRescheduleSubmit) {
712
+ try {
713
+ await onRescheduleSubmit({ newStartTime, newEndTime });
714
+ setSuccess('Reschedule confirmed.');
715
+ setMobileStep(4);
716
+ }
717
+ catch (nextError) {
718
+ setError(nextError instanceof Error
719
+ ? nextError.message
720
+ : 'Unable to reschedule. Please try again.');
721
+ }
722
+ finally {
723
+ setIsSubmitting(false);
724
+ }
725
+ return;
726
+ }
298
727
  try {
299
- await client.createPublicBooking({
728
+ const created = await client.createPublicBooking({
300
729
  siteSlug,
301
730
  serviceId: selectedServiceId,
302
- staffMemberId: heldStaffId ?? selectedStaffId ?? selectedSlot.availableStaffIds[0],
303
- startTime: Date.parse(`${selectedDate}T${selectedSlot.time}:00`),
304
- endTime: Date.parse(`${selectedDate}T${selectedSlot.endTime}:00`),
305
- timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? 'UTC',
731
+ staffMemberId: selectedServiceRequiresSlot
732
+ ? heldStaffId ?? selectedStaffId ?? selectedSlot?.availableStaffIds[0]
733
+ : undefined,
734
+ startTime: newStartTime,
735
+ endTime: newEndTime,
736
+ timezone: selectedHeldStaff?.timezone ?? availableStaff[0]?.timezone ?? setup?.workspaceTimezone ?? 'UTC',
306
737
  deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
307
738
  customerName: customerName.trim(),
308
739
  customerEmail: customerEmail.trim(),
309
740
  customerPhone: customerPhone.trim() || undefined,
310
741
  customerNotes: customerNotes.trim() || undefined,
311
- bookingHoldId: holdId,
312
- bookingSessionToken: sessionToken,
742
+ intakeResponses,
743
+ quotedTotalCents: quote?.totalCents,
744
+ bookingHoldId: selectedServiceRequiresSlot ? holdId : undefined,
745
+ bookingSessionToken: selectedServiceRequiresSlot ? sessionToken : undefined,
313
746
  });
747
+ if (created.payment) {
748
+ setPayment(created.payment);
749
+ setPaymentAppointmentId(created.appointmentId);
750
+ setError(null);
751
+ return;
752
+ }
314
753
  setSuccess('Booking confirmed.');
315
754
  setSelectedSlot(null);
316
755
  setPendingSlotKey(null);
@@ -321,6 +760,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
321
760
  setCustomerEmail('');
322
761
  setCustomerPhone('');
323
762
  setCustomerNotes('');
763
+ setIntakeResponses({});
764
+ setQuote(null);
765
+ setPayment(null);
766
+ setPaymentAppointmentId(null);
324
767
  setMobileStep(4);
325
768
  }
326
769
  catch (nextError) {
@@ -336,133 +779,534 @@ export function BookingWidgetPanel({ title, description, mobileHeader, }) {
336
779
  if (!setup || setup.services.length === 0) {
337
780
  return (_jsx("section", { className: "bw", children: _jsx("div", { className: "bw-empty", children: "No bookable services are available yet." }) }));
338
781
  }
339
- if (success) {
340
- return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(CheckIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: "You're All Set!" }), _jsx("p", { className: "bw-done-text", children: "Your booking request has been submitted. We'll follow up using the email you provided if anything needs confirmation." }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
341
- setSuccess(null);
342
- setSelectedDate(findFirstAvailableDate(availabilityByDate, calendarMonth));
343
- }, children: "Book another visit" })] }) }));
344
- }
345
- return (_jsxs("section", { className: "bw", children: [mobileHeader ? _jsx("div", { className: "bw-mobile-header", children: mobileHeader }) : null, _jsxs("div", { className: "bw-header", children: [_jsxs("div", { className: "bw-header-row", children: [_jsx("h2", { className: "bw-title bw-title--desktop", children: title || 'Schedule your visit' }), _jsx("h2", { className: "bw-title bw-title--mobile", children: mobileStepTitle(mobileStep) }), _jsx("div", { className: "bw-progress", "aria-hidden": "true", children: MOBILE_PROGRESS_STEPS.map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("label", { className: "bw-label", children: "Select Specialist" }), _jsxs("div", { className: "bw-staff-dropdown", ref: staffMenuRef, children: [_jsxs("button", { type: "button", className: `bw-staff-trigger${selectedStaff ? ' has-value' : ''}`, onClick: () => setStaffOpen((current) => !current), children: [_jsxs("span", { className: "bw-staff-trigger-content", children: [_jsx("span", { className: "bw-staff-avatar", children: selectedStaff ? (_jsx("span", { className: "bw-staff-initials", children: getInitials(selectedStaff.name) })) : (_jsx(UserIcon, {})) }), _jsxs("span", { className: "bw-staff-trigger-info", children: [_jsx("span", { className: "bw-staff-trigger-name", children: selectedStaff ? selectedStaff.name : 'Any available specialist' }), _jsx("span", { className: "bw-staff-trigger-desc", children: selectedStaff ? selectedStaff.timezone : 'First available team member' })] })] }), _jsx("span", { className: "bw-staff-trigger-chevron", children: _jsx(ChevronDownIcon, {}) })] }), staffOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-staff-overlay", "aria-label": "Close specialist menu", onClick: () => setStaffOpen(false) }), _jsxs("div", { className: "bw-staff-list", children: [_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => {
346
- setSelectedStaffId(null);
347
- setStaffOpen(false);
348
- }, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx(UserIcon, {}) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: "Any Available" }), _jsx("span", { className: "bw-staff-option-desc", children: "First available specialist" })] })] }), availableStaff.map((staffMember) => (_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === staffMember._id ? ' is-active' : ''}`, onClick: () => {
349
- setSelectedStaffId(staffMember._id);
350
- setStaffOpen(false);
351
- }, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) }) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: staffMember.name }), _jsx("span", { className: "bw-staff-option-desc", children: staffMember.timezone })] })] }, staffMember._id)))] })] })) : null] }), _jsxs("div", { className: "bw-service-picker", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: "Select Service" }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: setup.services.map((service) => {
352
- const isActive = selectedServiceId === service._id;
353
- return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
782
+ if (forcedState === 'cancelled') {
783
+ 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: "Booking cancelled" }), _jsx("p", { className: "bw-done-text", children: "Your appointment has been cancelled. We've let your provider know. You can book a new visit any time." }), _jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
784
+ /* dev preview only — host wires the real handler */
785
+ }, children: "Book a new visit" })] }) }));
786
+ }
787
+ if (success || forcedState === 'success') {
788
+ return (_jsx("section", { className: "bw", children: _jsxs("div", { className: "bw-done", children: [_jsx("div", { className: "bw-done-icon", children: _jsx(CheckIcon, {}) }), _jsx("h3", { className: "bw-done-title", children: isReschedule ? 'All set.' : "You're All Set!" }), _jsx("p", { className: "bw-done-text", children: isReschedule
789
+ ? "Your appointment has been rescheduled. We've sent a confirmation to the email on file and notified your provider."
790
+ : "Your booking request has been submitted. We'll follow up using the email you provided if anything needs confirmation." }), isReschedule ? null : (_jsx("button", { type: "button", className: "bw-btn-primary", onClick: () => {
791
+ window.location.assign(successRedirectHref);
792
+ }, children: "Back to home" }))] }) }));
793
+ }
794
+ return (_jsxs("section", { className: "bw", "data-view-state": viewState, ref: widgetRootRef, children: [mobileHeader ? _jsx("div", { className: "bw-mobile-header", children: mobileHeader }) : null, _jsxs("div", { className: "bw-header", children: [_jsxs("div", { className: "bw-header-row", children: [_jsx("h2", { className: "bw-title bw-title--desktop", children: title ||
795
+ (isReschedule
796
+ ? rescheduleContext?.customerName
797
+ ? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
798
+ : 'Reschedule appointment'
799
+ : 'Schedule your visit') }), _jsx("h2", { className: "bw-title bw-title--mobile", children: isReschedule
800
+ ? rescheduleContext?.customerName
801
+ ? `Reschedule ${formatPossessive(rescheduleContext.customerName)} appointment`
802
+ : 'Reschedule appointment'
803
+ : mobileStepTitle(mobileStep) }), _jsx("div", { className: "bw-progress", "aria-hidden": "true", children: MOBILE_PROGRESS_STEPS.map((step) => (_jsx("span", { className: `bw-dot${mobileStep >= step ? ' is-filled' : ''}` }, step))) })] }), description ? _jsx("p", { className: "bw-description", children: description }) : null] }), _jsxs("div", { className: "bw-content", children: [_jsx("div", { className: "bw-body", "data-mobile-step": mobileStep, children: _jsxs("div", { className: "bw-body-inner", children: [_jsxs("div", { className: "bw-col bw-col--left", children: [_jsxs("div", { className: "bw-step-1", children: [_jsx("div", { className: "bw-service-picker", children: selectedService && !isEditingService ? (_jsxs("div", { className: "bw-svc-selected", children: [_jsxs("div", { className: "bw-svc-selected-header", children: [_jsx("label", { className: "bw-label", children: "Selected service" }), _jsx("button", { type: "button", className: "bw-svc-change", onClick: () => setIsEditingService(true), children: "Change" })] }), _jsxs("div", { className: "bw-svc-row bw-svc-row--readonly", children: [selectedService.image ? (_jsxs("span", { className: "bw-svc-image is-checked", children: [_jsx("img", { src: selectedService.image.url, alt: selectedService.image.alt ?? selectedService.name, loading: "lazy" }), _jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })] })) : (_jsx("span", { className: "bw-check is-checked", children: _jsx(CheckIcon, {}) })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: selectedService.name }), selectedService.description ? (_jsx("span", { className: "bw-svc-desc", children: selectedService.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(selectedService.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(selectedService) })] })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { className: "bw-label", children: "Select Service" }), _jsx("div", { className: "bw-svc-scroll is-open", children: _jsx("div", { className: "bw-svc-scroll-wrap", children: _jsx("div", { className: "bw-svc-scroll-inner", children: serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
804
+ const isActive = selectedServiceId === service._id;
805
+ return (_jsxs("button", { type: "button", className: "bw-svc-row", onMouseEnter: () => {
806
+ void prefetchAvailability({
807
+ client,
808
+ siteSlug,
809
+ selectedServiceId: service._id,
810
+ selectedStaffId,
811
+ calendarMonth,
812
+ cacheRef: availabilityCacheRef,
813
+ });
814
+ }, onFocus: () => {
815
+ void prefetchAvailability({
816
+ client,
817
+ siteSlug,
818
+ selectedServiceId: service._id,
819
+ selectedStaffId,
820
+ calendarMonth,
821
+ cacheRef: availabilityCacheRef,
822
+ });
823
+ }, onClick: () => {
824
+ handleServiceSelect(service);
825
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service) })] })] })] }, service._id));
826
+ })] }, group.key))) }) }) })] })) }), selectedService && selectedServiceRequiresSlot && !isEditingService ? (_jsxs("div", { className: "bw-provider-section", children: [_jsx("div", { className: "bw-section-divider" }), _jsx("label", { className: "bw-label", children: "Select a Provider" }), _jsxs("div", { className: "bw-staff-dropdown", ref: staffMenuRef, children: [_jsxs("button", { type: "button", className: `bw-staff-trigger${selectedStaff ? ' has-value' : ''}`, onClick: () => setStaffOpen((current) => !current), children: [_jsxs("span", { className: "bw-staff-trigger-content", children: [_jsx("span", { className: "bw-staff-avatar", children: selectedStaff?.image?.url ? (_jsx("img", { src: selectedStaff.image.url, alt: selectedStaff.image.alt ?? selectedStaff.name, className: "bw-staff-avatar-img" })) : selectedStaff ? (_jsx("span", { className: "bw-staff-initials", children: getInitials(selectedStaff.name) })) : (_jsx(UserIcon, {})) }), _jsxs("span", { className: "bw-staff-trigger-info", children: [_jsx("span", { className: "bw-staff-trigger-name", children: selectedStaff ? selectedStaff.name : 'Any provider' }), selectedStaff ? null : (_jsx("span", { className: "bw-staff-trigger-desc", children: "First available" }))] })] }), _jsx("span", { className: "bw-staff-trigger-chevron", children: _jsx(ChevronDownIcon, {}) })] }), staffOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-staff-overlay", "aria-label": "Close provider menu", onClick: () => setStaffOpen(false) }), _jsxs("div", { className: "bw-staff-list", children: [_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === null ? ' is-active' : ''}`, onClick: () => {
827
+ setSelectedStaffId(null);
828
+ setStaffOpen(false);
829
+ }, children: [_jsx("span", { className: "bw-staff-avatar", children: _jsx(UserIcon, {}) }), _jsxs("span", { className: "bw-staff-option-info", children: [_jsx("span", { className: "bw-staff-option-name", children: "Any Available" }), _jsx("span", { className: "bw-staff-option-desc", children: "First available" })] })] }), availableStaff.map((staffMember) => (_jsxs("button", { type: "button", className: `bw-staff-option${selectedStaffId === staffMember._id ? ' is-active' : ''}`, onClick: () => {
830
+ setSelectedStaffId(staffMember._id);
831
+ setStaffOpen(false);
832
+ }, children: [_jsx("span", { className: "bw-staff-avatar", children: staffMember.image?.url ? (_jsx("img", { src: staffMember.image.url, alt: staffMember.image.alt ?? staffMember.name, className: "bw-staff-avatar-img" })) : (_jsx("span", { className: "bw-staff-initials", children: getInitials(staffMember.name) })) }), _jsx("span", { className: "bw-staff-option-info", children: _jsx("span", { className: "bw-staff-option-name", children: staffMember.name }) })] }, staffMember._id)))] })] })) : null] })] })) : null] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: "Choose your service" }), _jsx("div", { className: "bw-step-2-divider" }), serviceGroups.map((group) => (_jsxs("div", { className: "bw-svc-group", children: [group.categoryName ? (_jsx("div", { className: "bw-svc-category", children: group.categoryName })) : null, group.services.map((service) => {
833
+ const isActive = selectedServiceId === service._id;
834
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
835
+ void prefetchAvailability({
836
+ client,
837
+ siteSlug,
838
+ selectedServiceId: service._id,
839
+ selectedStaffId,
840
+ calendarMonth,
841
+ cacheRef: availabilityCacheRef,
842
+ });
843
+ }, onFocus: () => {
844
+ void prefetchAvailability({
845
+ client,
846
+ siteSlug,
847
+ selectedServiceId: service._id,
848
+ selectedStaffId,
849
+ calendarMonth,
850
+ cacheRef: availabilityCacheRef,
851
+ });
852
+ }, onClick: () => {
853
+ handleServiceSelect(service);
854
+ }, children: [service.image ? (_jsxs("span", { className: `bw-svc-image bw-svc-image--lg${isActive ? ' is-checked' : ''}`, children: [_jsx("img", { src: service.image.url, alt: service.image.alt ?? service.name, loading: "lazy" }), isActive ? (_jsx("span", { className: "bw-svc-image-badge", children: _jsx(CheckIcon, {}) })) : null] })) : (_jsx("span", { className: `bw-check bw-check--lg${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null })), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null, _jsxs("span", { className: "bw-svc-meta-row", children: [_jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), _jsx("span", { className: "bw-svc-meta-dot", "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "bw-svc-price", children: formatServicePrice(service) })] })] })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
855
+ })] }, group.key)))] })] }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card", ref: calCardRef, children: [_jsxs("div", { className: "bw-cal-header", children: [_jsxs("div", { className: "bw-month-dropdown", children: [_jsxs("button", { type: "button", className: "bw-month-btn", onClick: () => setMonthOpen((current) => !current), children: [_jsx("span", { children: formatMonthLabel(calendarMonth) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": "Close month picker", onClick: () => setMonthOpen(false) }), _jsx("div", { className: "bw-month-list", children: monthOptions.map((option) => (_jsx("button", { type: "button", className: `bw-month-option${option.value === optionCurrentValue(calendarMonth) ? ' is-active' : ''}`, onClick: () => {
856
+ setCalendarMonth(option.date);
857
+ setMonthOpen(false);
858
+ }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
859
+ if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
354
860
  void prefetchAvailability({
355
861
  client,
356
862
  siteSlug,
357
- selectedServiceId: service._id,
863
+ selectedServiceId,
358
864
  selectedStaffId,
359
- calendarMonth,
865
+ calendarMonth: addMonths(calendarMonth, -1),
360
866
  cacheRef: availabilityCacheRef,
361
867
  });
362
- }, onFocus: () => {
868
+ }
869
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": "Previous month", children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
870
+ if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
363
871
  void prefetchAvailability({
364
872
  client,
365
873
  siteSlug,
366
- selectedServiceId: service._id,
874
+ selectedServiceId,
367
875
  selectedStaffId,
368
- calendarMonth,
876
+ calendarMonth: addMonths(calendarMonth, 1),
369
877
  cacheRef: availabilityCacheRef,
370
878
  });
371
- }, onClick: () => {
372
- setSelectedServiceId(service._id);
373
- setMobileStep(2);
374
- }, children: [_jsx("span", { className: `bw-check${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null }), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), _jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null] }), _jsx("span", { className: "bw-svc-price", children: currencyFormatter(service.currency).format(service.priceCents / 100) })] }, service._id));
375
- }) }) }) })] })] }), _jsxs("div", { className: "bw-step-2", children: [_jsx("p", { className: "bw-step-2-heading", children: "Choose your service" }), _jsx("div", { className: "bw-step-2-divider" }), setup.services.map((service) => {
376
- const isActive = selectedServiceId === service._id;
377
- return (_jsxs("div", { children: [_jsxs("button", { type: "button", className: "bw-svc-row bw-svc-row--full", onMouseEnter: () => {
378
- void prefetchAvailability({
379
- client,
380
- siteSlug,
381
- selectedServiceId: service._id,
382
- selectedStaffId,
383
- calendarMonth,
384
- cacheRef: availabilityCacheRef,
385
- });
386
- }, onFocus: () => {
387
- void prefetchAvailability({
388
- client,
389
- siteSlug,
390
- selectedServiceId: service._id,
391
- selectedStaffId,
392
- calendarMonth,
393
- cacheRef: availabilityCacheRef,
394
- });
395
- }, onClick: () => setSelectedServiceId(service._id), children: [_jsx("span", { className: `bw-check bw-check--lg${isActive ? ' is-checked' : ''}`, children: isActive ? _jsx(CheckIcon, {}) : null }), _jsxs("span", { className: "bw-svc-info", children: [_jsx("span", { className: "bw-svc-name", children: service.name }), _jsx("span", { className: "bw-svc-meta", children: formatDuration(service.durationMinutes) }), service.description ? (_jsx("span", { className: "bw-svc-desc", children: service.description })) : null] }), _jsx("span", { className: "bw-svc-price", children: currencyFormatter(service.currency).format(service.priceCents / 100) })] }), _jsx("div", { className: "bw-row-divider" })] }, service._id));
396
- })] })] }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card", children: [_jsxs("div", { className: "bw-cal-header", children: [_jsxs("div", { className: "bw-month-dropdown", children: [_jsxs("button", { type: "button", className: "bw-month-btn", onClick: () => setMonthOpen((current) => !current), children: [_jsx("span", { children: formatMonthLabel(calendarMonth) }), _jsx(ChevronDownIcon, {})] }), monthOpen ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bw-month-overlay", "aria-label": "Close month picker", onClick: () => setMonthOpen(false) }), _jsx("div", { className: "bw-month-list", children: monthOptions.map((option) => (_jsx("button", { type: "button", className: `bw-month-option${option.value === optionCurrentValue(calendarMonth) ? ' is-active' : ''}`, onClick: () => {
397
- setCalendarMonth(option.date);
398
- setMonthOpen(false);
399
- }, children: option.label }, option.value))) })] })) : null] }), _jsxs("div", { className: "bw-cal-navs", children: [_jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
400
- if (!sameMonth(calendarMonth, startOfMonth(new Date())) && selectedServiceId) {
401
- void prefetchAvailability({
402
- client,
403
- siteSlug,
404
- selectedServiceId,
405
- selectedStaffId,
406
- calendarMonth: addMonths(calendarMonth, -1),
407
- cacheRef: availabilityCacheRef,
408
- });
409
- }
410
- }, onClick: () => setCalendarMonth((current) => addMonths(current, -1)), disabled: sameMonth(calendarMonth, startOfMonth(new Date())), "aria-label": "Previous month", children: _jsx(ChevronLeftIcon, {}) }), _jsx("button", { type: "button", className: "bw-cal-nav", onMouseEnter: () => {
411
- if (!sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)) && selectedServiceId) {
412
- void prefetchAvailability({
413
- client,
414
- siteSlug,
415
- selectedServiceId,
416
- selectedStaffId,
417
- calendarMonth: addMonths(calendarMonth, 1),
418
- cacheRef: availabilityCacheRef,
419
- });
420
- }
421
- }, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": "Next month", children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
422
- const dateKey = formatDateKey(day);
423
- const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
424
- const isPast = dateKey < formatDateKey(startOfDay(new Date()));
425
- const slots = availabilityByDate.get(dateKey) ?? [];
426
- const isAvailable = slots.length > 0;
427
- const isSelected = selectedDate === dateKey;
428
- const className = [
429
- 'bw-cal-day',
430
- !isCurrentMonth ? 'is-outside' : '',
431
- isSelected ? 'is-selected' : '',
432
- isAvailable && !isPast ? 'is-available' : '',
433
- isPast ? 'is-disabled' : '',
434
- !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
435
- dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
436
- ]
437
- .filter(Boolean)
438
- .join(' ');
439
- return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
440
- setSelectedDate(dateKey);
441
- setSelectedSlot(null);
442
- setMobileStep((current) => (current < 3 ? 3 : current));
443
- }, children: day.getDate() }, dateKey));
444
- }) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: "Please select a service to see availability." })) : null, selectedService && isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : null, selectedService && selectedDate && !isAvailabilityLoading ? (selectedDateSlots.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "bw-time-divider" }), _jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot) => {
445
- const slotKey = `${slot.time}-${slot.endTime}`;
446
- const isPending = pendingSlotKey === slotKey;
447
- const isActive = isPending ||
448
- (selectedSlot?.time === slot.time && selectedSlot?.endTime === slot.endTime);
449
- return (_jsx("button", { type: "button", className: `bw-slot${isActive ? ' is-active' : ''}${isPending ? ' is-pending' : ''}`, onClick: () => void handleSlotSelect(slot), disabled: isSubmitting, children: isPending ? 'Securing...' : formatTimeLabel(slot.time) }, slotKey));
450
- }) })] })) : (_jsx("p", { className: "bw-no-slots", children: "No available times for this date." }))) : null, _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? 'UTC' })] })] }) }), _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-form", children: [_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: "bw-name", children: ["Full name ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: "bw-name", value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: "John Doe" })] }), _jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: "bw-email", children: ["Email ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: "bw-email", type: "email", value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), placeholder: "jane@example.com" })] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: "bw-phone", children: "Phone" }), _jsx("input", { id: "bw-phone", value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: "+1 (555) 000-0000" })] })] }), _jsxs("div", { className: "bw-field bw-field--notes", children: [_jsx("label", { htmlFor: "bw-notes", children: "Additional notes" }), _jsx("textarea", { id: "bw-notes", rows: 3, maxLength: 500, value: customerNotes, onChange: (event) => setCustomerNotes(event.target.value), placeholder: "Any preferences or special requests..." }), _jsxs("span", { className: "bw-char-count", children: [customerNotes.length, "/500"] })] }), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: "Booking Summary" }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Specialist" }), _jsx("span", { className: "bw-summary-val", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: "Estimated total" }), _jsx("span", { className: "bw-summary-val", children: currencyFormatter(selectedService.currency).format(selectedService.priceCents / 100) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, _jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting ? 'Booking...' : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-footer", children: [mobileStep === 4 && selectedService ? (_jsxs("div", { className: "bw-footer-summary", children: [_jsx("span", { className: "bw-summary-title", children: "Booking Summary" }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Specialist" }), _jsx("span", { className: "bw-summary-val", children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: "Estimated total" }), _jsx("span", { className: "bw-summary-val", children: currencyFormatter(selectedService.currency).format(selectedService.priceCents / 100) })] })] })] })) : null, _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => setMobileStep((step) => Math.max(1, step - 1)), children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
879
+ }
880
+ }, onClick: () => setCalendarMonth((current) => addMonths(current, 1)), disabled: sameMonth(calendarMonth, addMonths(startOfMonth(new Date()), 3)), "aria-label": "Next month", children: _jsx(ChevronRightIcon, {}) })] })] }), _jsx("div", { className: "bw-cal-weekdays", children: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (_jsx("span", { children: day }, day))) }), _jsx("div", { className: "bw-cal-grid", children: calendarDays.map((day) => {
881
+ const dateKey = formatDateKey(day);
882
+ const isCurrentMonth = day.getMonth() === calendarMonth.getMonth();
883
+ const isPast = dateKey < formatDateKey(startOfDay(new Date()));
884
+ const slots = availabilityByDate.get(dateKey) ?? [];
885
+ const isAvailable = slots.length > 0;
886
+ const isSelected = selectedDate === dateKey;
887
+ const className = [
888
+ 'bw-cal-day',
889
+ !isCurrentMonth ? 'is-outside' : '',
890
+ isSelected ? 'is-selected' : '',
891
+ isAvailable && !isPast ? 'is-available' : '',
892
+ isPast ? 'is-disabled' : '',
893
+ !isAvailable && isCurrentMonth && !isPast ? 'is-blocked' : '',
894
+ dateKey === formatDateKey(startOfDay(new Date())) ? 'is-today' : '',
895
+ ]
896
+ .filter(Boolean)
897
+ .join(' ');
898
+ return (_jsx("button", { type: "button", className: className, disabled: !isCurrentMonth || isPast || !isAvailable, onClick: () => {
899
+ setSelectedDate(dateKey);
900
+ setSelectedSlot(null);
901
+ setMobileStep((current) => (current < 3 ? 3 : current));
902
+ }, children: day.getDate() }, dateKey));
903
+ }) }), !selectedService ? (_jsx("p", { className: "bw-cal-prompt", children: "Please select a service to see availability." })) : null, _jsxs("div", { className: "bw-slots-mobile", children: [selectedService && selectedDate && selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-divider" })) : null, slotsArea] }), _jsxs("div", { className: "bw-timezone", children: [_jsx(GlobeIcon, {}), _jsx("span", { children: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] })] }) }), _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: [_jsx("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: _jsxs("div", { className: "bw-slots-desktop", children: [_jsxs("div", { className: "bw-slots-heading", children: [_jsx("span", { children: "Available times" }), selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), 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: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? _jsx(ServiceWarningCallout, { warning: serviceWarning }) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', 'Additional notes', 'Any preferences or special requests...'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? 'Reschedule Summary' : 'Booking Summary' }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
904
+ ...summaryValStyle,
905
+ textDecoration: 'line-through',
906
+ opacity: 0.55,
907
+ }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
908
+ ...summaryValStyle,
909
+ textDecoration: 'line-through',
910
+ opacity: 0.55,
911
+ }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
912
+ ...summaryValStyle,
913
+ textDecoration: 'line-through',
914
+ opacity: 0.55,
915
+ }, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, showInlineButton: false, actionTarget: mobilePaymentActionTarget, onSuccess: () => {
916
+ setSuccess('Payment received. Your booking is being confirmed.');
917
+ setPayment(null);
918
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
919
+ ? isReschedule
920
+ ? 'Rescheduling...'
921
+ : 'Booking...'
922
+ : isReschedule
923
+ ? 'Confirm Reschedule'
924
+ : selectedServiceRequiresPayment
925
+ ? 'Continue to payment'
926
+ : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }) }) }) })] }) }), _jsxs("div", { className: "bw-details-view", children: [_jsx("div", { className: "bw-details-summary", children: selectedService ? (_jsxs(_Fragment, { children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different time" })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn bw-back-btn--details", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: "Pick a different service" })] })), _jsx("h3", { className: "bw-details-service", children: selectedService.name }), selectedService.description ? (_jsx("p", { className: "bw-details-desc", children: selectedService.description })) : null, _jsxs("div", { className: "bw-details-meta", children: [isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--strike", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: "Former time" }), _jsx("span", { className: "bw-details-meta-value", children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })] })) : null, selectedDate && selectedSlot ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(CalendarIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isReschedule ? 'New time' : 'When' }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate), _jsx("br", {}), formatTimeLabel(selectedSlot.time), " \u2013 ", formatTimeLabel(selectedSlot.endTime)] })] })] })) : 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 ?? selectedStaff?.name ?? 'Any Available' })] }), _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: selectedHeldStaff?.timezone ?? selectedStaff?.timezone ?? setup?.workspaceTimezone ?? setup?.staff?.[0]?.timezone ?? 'UTC' })] }), _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: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, quote) })] })] }), 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: "Reservation hold" }), _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'), renderNotesField('bw-notes-d', isReschedule ? 'Reason for reschedule' : 'Additional notes', isReschedule
927
+ ? 'Let your provider know why...'
928
+ : 'Any preferences or special requests...'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment && (forcedState === 'payment-full' || forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
929
+ setSuccess('Payment received. Your booking is being confirmed.');
930
+ setPayment(null);
931
+ }, onError: setError })) : (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
932
+ ? isReschedule
933
+ ? 'Rescheduling...'
934
+ : 'Booking...'
935
+ : forcedState === 'payment-full'
936
+ ? `Pay ${formatPrice(selectedService)} & Confirm`
937
+ : forcedState === 'payment-deposit'
938
+ ? `Pay deposit & Confirm`
939
+ : isReschedule
940
+ ? 'Confirm Reschedule'
941
+ : selectedServiceRequiresPayment
942
+ ? 'Continue to payment'
943
+ : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] }) })] })] }), _jsxs("div", { className: "bw-footer", children: [mobileStep === 4 && selectedService ? (_jsxs("div", { className: "bw-footer-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule ? 'Reschedule Summary' : 'Booking Summary' }), _jsxs("div", { className: "bw-summary-rows", children: [holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--hold", "aria-live": "polite", children: [_jsx("span", { children: "Reservation hold" }), _jsx("span", { className: "bw-summary-val bw-summary-val--hold", children: formatHoldCountdown(holdSecondsRemaining) })] })) : null, isReschedule && rescheduleContext ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former time" }), _jsx("span", { className: "bw-summary-val", style: {
944
+ ...summaryValStyle,
945
+ textDecoration: 'line-through',
946
+ opacity: 0.55,
947
+ }, children: formatFormerSlot(rescheduleContext.formerStartTime, rescheduleContext.formerEndTime) })] })) : null, serviceChanged && formerService ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former service" }), _jsx("span", { className: "bw-summary-val", style: {
948
+ ...summaryValStyle,
949
+ textDecoration: 'line-through',
950
+ opacity: 0.55,
951
+ }, children: formerService.name })] })) : null, staffChanged ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Former staff" }), _jsx("span", { className: "bw-summary-val", style: {
952
+ ...summaryValStyle,
953
+ textDecoration: 'line-through',
954
+ opacity: 0.55,
955
+ }, children: formerStaffName ?? 'Any Available' })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Service" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Provider" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ?? selectedStaff?.name ?? 'Any Available' })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Date" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate) })] })) : null, selectedSlot ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Time" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatTimeLabel(selectedSlot.time) })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: "Duration" }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isQuoteLoading ? 'Calculating...' : 'Estimated total' }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, quote) })] })] })] })) : null, _jsxs("div", { className: "bw-footer-btns", children: [mobileStep > 1 ? (_jsx("button", { type: "button", className: "bw-footer-back", onClick: () => {
956
+ if (!selectedServiceRequiresSlot && mobileStep === 4) {
957
+ handleBackToServicePicker();
958
+ }
959
+ else {
960
+ setMobileStep((step) => Math.max(1, step - 1));
961
+ }
962
+ }, children: _jsx(ArrowLeftIcon, {}) })) : null, mobileStep < 4 ? (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: (mobileStep === 1 && !canAdvanceStep1) ||
451
963
  (mobileStep === 2 && !canAdvanceStep2) ||
452
- (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting ? 'Booking...' : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
964
+ (mobileStep === 3 && !canAdvanceStep3), onClick: () => setMobileStep((step) => Math.min(4, step + 1)), children: [_jsx("span", { children: "Next" }), _jsx(ArrowRightIcon, {})] })) : payment ? (_jsx("div", { className: "bw-footer-payment-slot", ref: setMobilePaymentActionTarget })) : (_jsxs("button", { type: "button", className: "bw-footer-next", disabled: !canSubmit || isQuoteLoading, onClick: () => void handleConfirmBooking(), children: [_jsx("span", { children: isSubmitting
965
+ ? isReschedule
966
+ ? 'Rescheduling...'
967
+ : 'Booking...'
968
+ : isReschedule
969
+ ? 'Confirm Reschedule'
970
+ : selectedServiceRequiresPayment
971
+ ? 'Continue to payment'
972
+ : 'Confirm Booking' }), !isSubmitting ? _jsx(ArrowRightIcon, {}) : null] }))] })] })] }));
973
+ }
974
+ /* Payment panel — design proposal for the pay-before-booking flow.
975
+ * Renders inside the desktop details form (and mobile step-4 form
976
+ * once wired) when the selected service has paymentMode === 'full'
977
+ * or 'deposit'. Visuals only today: real implementation will mount
978
+ * a Stripe PaymentElement inside .bw-pay-card-slot and gate the
979
+ * Confirm CTA on a successful charge. Deposit mode shows the split
980
+ * (charged today vs at-visit) so the customer sees what's actually
981
+ * coming out of their card now.
982
+ *
983
+ * TODO(eric): Eric is adding a Stripe widget to the LEFT side of
984
+ * the details view (alongside the summary, not the form). When you
985
+ * pick this up:
986
+ * 1. Move/replace this PaymentPanel into the left summary pane
987
+ * (.bw-details-summary) so the Stripe Element sits where the
988
+ * customer reads what they're paying for. The right form pane
989
+ * stays as-is — name/email/phone/notes only.
990
+ * 2. Backend gap: getPublicBookingSetup currently strips the
991
+ * payment fields. Surface paymentMode, depositCents,
992
+ * requiresPayment, and the workspace's Stripe Connect account
993
+ * handle (so the PaymentElement can be configured against the
994
+ * right tenant) in the public response + the
995
+ * PublicBookingSetup type.
996
+ * 3. Mount Elements provider scoped to the widget; create a
997
+ * PaymentIntent on hold success (server-side mutation pinned
998
+ * to the bookingHoldId so the amount can't be tampered with).
999
+ * 4. Gate createPublicAppointment behind paymentIntent.status
1000
+ * === 'succeeded'. On failure: keep the hold for one retry.
1001
+ * 5. Confirm CTA copy already branches on forcedState — wire the
1002
+ * same branch on the real `service.requiresPayment` field.
1003
+ * 6. Visual reference: this PaymentPanel + the dev preview pills
1004
+ * ('Pay full' / 'Pay deposit') in dev/booking-widget-preview
1005
+ * show the design intent for the summary card layout. Reuse
1006
+ * the .bw-pay-summary chrome on the left side; the
1007
+ * .bw-pay-card-slot is what gets replaced by <PaymentElement />.
1008
+ */
1009
+ function PaymentPanel({ service, mode, }) {
1010
+ if (!service)
1011
+ return null;
1012
+ const formatter = currencyFormatter(service.currency);
1013
+ const total = service.priceCents / 100;
1014
+ // Deposit defaults to 30% of total when no explicit depositCents
1015
+ // is set on the service. Real flow reads `service.depositCents`.
1016
+ const depositToday = mode === 'full' ? total : Math.round(total * 0.3 * 100) / 100;
1017
+ const dueAtVisit = mode === 'full' ? 0 : Math.round((total - depositToday) * 100) / 100;
1018
+ return (_jsxs("div", { className: "bw-pay", children: [_jsxs("div", { className: "bw-pay-summary", children: [_jsx("div", { className: "bw-pay-summary-header", children: "Summary" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: service.name }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row", children: [_jsx("span", { children: "Subtotal" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: "Tax" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(0) })] }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--total", children: [_jsx("span", { children: "Total" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(total) })] }), _jsx("div", { className: "bw-pay-summary-divider" }), _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: "Charged today" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(depositToday) })] }), mode === 'deposit' ? (_jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--muted", children: [_jsx("span", { children: "Due at visit" }), _jsx("span", { className: "bw-pay-summary-val", children: formatter.format(dueAtVisit) })] })) : null] }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: "Card details" }), _jsx("div", { className: "bw-pay-card-slot", children: _jsxs("div", { className: "bw-pay-card-stub", children: [_jsxs("div", { className: "bw-pay-card-stub-row", children: [_jsx("span", { className: "bw-pay-card-stub-label", children: "Card number" }), _jsx("span", { className: "bw-pay-card-stub-value", children: "1234 1234 1234 1234" })] }), _jsxs("div", { className: "bw-pay-card-stub-grid", children: [_jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "MM / YY" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "CVC" }) }), _jsx("div", { className: "bw-pay-card-stub-row", children: _jsx("span", { className: "bw-pay-card-stub-label", children: "ZIP" }) })] })] }) }), _jsxs("p", { className: "bw-pay-secure", children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDD12" }), " Payments are processed securely by Stripe."] })] })] }));
1019
+ }
1020
+ function IntakeFormFields({ form, responses, onChange, idPrefix, mode, }) {
1021
+ if (!form || form.sections.length === 0)
1022
+ return null;
1023
+ return (_jsx("div", { className: "bw-intake", children: form.sections
1024
+ .filter((section) => !section.showWhenPath || section.showWhenPath === mode)
1025
+ .map((section) => (_jsxs("div", { className: "bw-intake-section", children: [_jsx("div", { className: "bw-intake-title", children: section.titleEs ?? section.title ?? section.titleEn ?? section.id }), _jsx("div", { className: "bw-form-fields", children: section.fields.map((field) => (_jsx(IntakeField, { field: field, value: responses[field.id], onChange: (value) => onChange(field.id, value), idPrefix: `${idPrefix}-${section.id}` }, field.id))) })] }, section.id))) }));
1026
+ }
1027
+ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1028
+ return form.sections
1029
+ .filter((section) => !section.showWhenPath || section.showWhenPath === mode)
1030
+ .every((section) => section.fields.every((field) => {
1031
+ const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1032
+ ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1033
+ : undefined;
1034
+ return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1035
+ }));
1036
+ }
1037
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, selectedDate, selectedSlot, holdId, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, }) {
1038
+ const blockers = [];
1039
+ if (selectedServiceRequiresSlot && (!selectedDate || !selectedSlot || !holdId)) {
1040
+ blockers.push('Date and time');
1041
+ }
1042
+ if (!customerName.trim())
1043
+ blockers.push('Contact: Full name');
1044
+ if (!customerEmail.trim()) {
1045
+ blockers.push('Contact: Email');
1046
+ }
1047
+ else if (!isValidEmailAddress(customerEmail)) {
1048
+ blockers.push('Contact: Enter a valid email address');
1049
+ }
1050
+ if (!isIntakeComplete && selectedService.intakeForm) {
1051
+ blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath));
1052
+ }
1053
+ if (selectedService.pricingMode === 'calculated' &&
1054
+ isIntakeComplete &&
1055
+ !quote) {
1056
+ blockers.push('Calculated price');
1057
+ }
1058
+ return Array.from(new Set(blockers));
1059
+ }
1060
+ function isValidEmailAddress(value) {
1061
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
1062
+ }
1063
+ function getMissingRequiredIntakeLabels(form, responses, mode) {
1064
+ const missing = [];
1065
+ for (const section of form.sections) {
1066
+ if (section.showWhenPath && section.showWhenPath !== mode)
1067
+ continue;
1068
+ const sectionLabel = getIntakeSectionLabel(section);
1069
+ for (const field of section.fields) {
1070
+ const minItems = field.type === 'repeatable-group' && field.id === 'boxes'
1071
+ ? Math.max(1, Math.round(readFiniteNumber(responses.box_count, 1)))
1072
+ : undefined;
1073
+ collectMissingIntakeFieldLabels(field, responses[field.id], sectionLabel, missing, minItems);
1074
+ }
1075
+ }
1076
+ return missing;
1077
+ }
1078
+ function collectMissingIntakeFieldLabels(field, value, sectionLabel, missing, minItems) {
1079
+ if (!field.required)
1080
+ return;
1081
+ if (field.type !== 'repeatable-group') {
1082
+ if (!isRequiredIntakeFieldComplete(field, value)) {
1083
+ missing.push(`${sectionLabel}: ${getIntakeFieldLabel(field)}`);
1084
+ }
1085
+ return;
1086
+ }
1087
+ const fieldLabel = getIntakeFieldLabel(field);
1088
+ const items = Array.isArray(value) ? value : [];
1089
+ const requiredItemCount = Math.max(minItems ?? 1, items.length || 1);
1090
+ if (items.length < requiredItemCount) {
1091
+ missing.push(`${sectionLabel}: ${fieldLabel}`);
1092
+ }
1093
+ for (let index = 0; index < requiredItemCount; index += 1) {
1094
+ const item = items[index];
1095
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1096
+ ? item
1097
+ : {};
1098
+ for (const child of field.fields ?? []) {
1099
+ if (!child.required)
1100
+ continue;
1101
+ if (!isRequiredIntakeFieldComplete(child, record[child.id])) {
1102
+ missing.push(`${sectionLabel}: Caja ${index + 1} ${getIntakeFieldLabel(child)}`);
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+ function getIntakeSectionLabel(section) {
1108
+ return section.titleEs ?? section.title ?? section.titleEn ?? section.id;
1109
+ }
1110
+ function getIntakeFieldLabel(field) {
1111
+ return field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1112
+ }
1113
+ function isRequiredIntakeFieldComplete(field, value, minItems) {
1114
+ if (!field.required)
1115
+ return true;
1116
+ if (field.type === 'checkbox') {
1117
+ return value === true;
1118
+ }
1119
+ if (field.type === 'repeatable-group') {
1120
+ if (!Array.isArray(value) || value.length < (minItems ?? 1))
1121
+ return false;
1122
+ return value.every((item) => {
1123
+ const record = item && typeof item === 'object' && !Array.isArray(item)
1124
+ ? item
1125
+ : {};
1126
+ return (field.fields ?? []).every((child) => isRequiredIntakeFieldComplete(child, record[child.id]));
1127
+ });
1128
+ }
1129
+ if (field.type === 'number') {
1130
+ if (typeof value === 'number')
1131
+ return Number.isFinite(value);
1132
+ if (typeof value === 'string')
1133
+ return value.trim().length > 0 && Number.isFinite(Number(value));
1134
+ return false;
1135
+ }
1136
+ return typeof value === 'string' && value.trim().length > 0;
1137
+ }
1138
+ function readFiniteNumber(value, fallback) {
1139
+ if (typeof value === 'number' && Number.isFinite(value))
1140
+ return value;
1141
+ if (typeof value === 'string' && value.trim()) {
1142
+ const parsed = Number(value);
1143
+ if (Number.isFinite(parsed))
1144
+ return parsed;
1145
+ }
1146
+ return fallback;
1147
+ }
1148
+ function IntakeField({ field, value, onChange, idPrefix, }) {
1149
+ const id = `${idPrefix}-${field.id}`;
1150
+ const label = field.labelEs ?? field.label ?? field.labelEn ?? field.id;
1151
+ const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
1152
+ if (field.type === 'repeatable-group') {
1153
+ const items = Array.isArray(value) && value.length > 0 ? value : [{}];
1154
+ return (_jsxs("div", { className: "bw-field bw-field--wide bw-repeatable", children: [_jsx("label", { children: label }), items.map((item, index) => {
1155
+ const record = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
1156
+ return (_jsxs("div", { className: "bw-repeatable-item", children: [_jsxs("div", { className: "bw-repeatable-head", children: [_jsxs("span", { children: ["Caja ", index + 1] }), items.length > 1 ? (_jsx("button", { type: "button", className: "bw-link-btn", onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)), children: "Quitar" })) : null] }), _jsx("div", { className: "bw-form-fields", children: (field.fields ?? []).map((child) => (_jsx(IntakeField, { field: child, value: record[child.id], idPrefix: `${id}-${index}`, onChange: (nextValue) => {
1157
+ const nextItems = [...items];
1158
+ nextItems[index] = { ...record, [child.id]: nextValue };
1159
+ onChange(nextItems);
1160
+ } }, child.id))) })] }, index));
1161
+ }), _jsx("button", { type: "button", className: "bw-secondary-btn", onClick: () => onChange([...items, {}]), children: "Agregar otra caja" })] }));
1162
+ }
1163
+ if (field.type === 'checkbox') {
1164
+ return (_jsxs("div", { className: "bw-field bw-field--wide bw-checkbox-field", children: [_jsxs("label", { htmlFor: id, children: [_jsx("input", { id: id, type: "checkbox", checked: value === true, onChange: (event) => onChange(event.target.checked) }), _jsxs("span", { children: [label, field.required ? ' *' : ''] })] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1165
+ }
1166
+ if (field.type === 'select') {
1167
+ return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsxs("select", { id: id, value: stringValue, required: field.required, onChange: (event) => onChange(event.target.value), children: [_jsx("option", { value: "", children: "Selecciona" }), (field.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.labelEs ?? option.labelEn ?? option.label }, option.value)))] }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1168
+ }
1169
+ if (field.type === 'textarea') {
1170
+ return (_jsxs("div", { className: "bw-field bw-field--wide", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx("textarea", { id: id, rows: 3, required: field.required, value: stringValue, placeholder: field.placeholder, onChange: (event) => onChange(event.target.value) }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1171
+ }
1172
+ return (_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: id, children: [label, field.required ? _jsx("span", { className: "bw-required", children: " *" }) : null] }), _jsx("input", { id: id, type: field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : 'text', min: field.min, max: field.max, required: field.required, value: stringValue, placeholder: field.placeholder, onChange: (event) => {
1173
+ onChange(field.type === 'number'
1174
+ ? event.target.value === ''
1175
+ ? ''
1176
+ : Number(event.target.value)
1177
+ : event.target.value);
1178
+ } }), field.helpText ? _jsx("p", { className: "bw-help", children: field.helpText }) : null] }));
1179
+ }
1180
+ function StripePaymentBlock({ payment, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1181
+ const stripePromise = useMemo(() => getStripePromise(payment.publishableKey, payment.connectAccountId), [payment.connectAccountId, payment.publishableKey]);
1182
+ return (_jsx(Elements, { stripe: stripePromise, options: {
1183
+ clientSecret: payment.clientSecret,
1184
+ }, children: _jsx(StripePaymentForm, { amountCents: payment.amountCents, currency: payment.currency, appointmentId: appointmentId, showInlineButton: showInlineButton, actionTarget: actionTarget, onSuccess: onSuccess, onError: onError }) }));
1185
+ }
1186
+ function StripePaymentForm({ amountCents, currency, appointmentId, showInlineButton = true, actionTarget, onSuccess, onError, }) {
1187
+ const stripe = useStripe();
1188
+ const elements = useElements();
1189
+ const [isPaying, setIsPaying] = useState(false);
1190
+ async function handlePay() {
1191
+ if (!stripe || !elements)
1192
+ return;
1193
+ setIsPaying(true);
1194
+ const result = await stripe.confirmPayment({
1195
+ elements,
1196
+ confirmParams: {
1197
+ return_url: `${window.location.origin}${window.location.pathname}?appointment_id=${appointmentId ?? ''}`,
1198
+ },
1199
+ redirect: 'if_required',
1200
+ });
1201
+ setIsPaying(false);
1202
+ if (result.error) {
1203
+ onError(result.error.message ?? 'Payment failed. Please try again.');
1204
+ return;
1205
+ }
1206
+ onSuccess();
1207
+ }
1208
+ const renderPayButton = () => (_jsxs("button", { type: "button", className: "bw-confirm-btn", disabled: !stripe || !elements || isPaying, onClick: () => void handlePay(), children: [_jsx("span", { children: isPaying ? 'Procesando...' : 'Pagar y confirmar' }), !isPaying ? _jsx(ArrowRightIcon, {}) : null] }));
1209
+ return (_jsxs("div", { className: "bw-pay", children: [_jsx("div", { className: "bw-pay-summary", children: _jsxs("div", { className: "bw-pay-summary-row bw-pay-summary-row--strong", children: [_jsx("span", { children: "Total due today" }), _jsx("span", { className: "bw-pay-summary-val", children: currencyFormatter(currency).format(amountCents / 100) })] }) }), _jsxs("div", { className: "bw-pay-card", children: [_jsx("label", { className: "bw-label", children: "Pago seguro" }), _jsx("div", { className: "bw-pay-card-slot", children: _jsx(PaymentElement, {}) })] }), showInlineButton ? renderPayButton() : null, actionTarget ? createPortal(renderPayButton(), actionTarget) : null] }));
1210
+ }
1211
+ function ServiceWarningCallout({ warning }) {
1212
+ return (_jsxs("div", { className: "bw-service-warning", role: "alert", children: [_jsx("div", { className: "bw-service-warning-icon", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }), _jsxs("div", { className: "bw-service-warning-copy", children: [_jsx("div", { className: "bw-service-warning-title", children: warning.title }), _jsx("p", { children: warning.body }), warning.linkHref && warning.linkLabel ? (_jsx("a", { href: warning.linkHref, className: "bw-service-warning-link", children: warning.linkLabel })) : null] })] }));
1213
+ }
1214
+ function formatPrice(service) {
1215
+ if (!service)
1216
+ return '';
1217
+ return formatServicePrice(service);
1218
+ }
1219
+ function formatServicePrice(service, quote) {
1220
+ if (service.pricingMode === 'calculated') {
1221
+ if (quote) {
1222
+ return currencyFormatter(quote.currency).format(quote.totalCents / 100);
1223
+ }
1224
+ return 'Price calculated after details';
1225
+ }
1226
+ return currencyFormatter(service.currency).format(service.priceCents / 100);
1227
+ }
1228
+ function getServiceWarning(service, mode) {
1229
+ if (!service || mode !== 'async')
1230
+ return null;
1231
+ const warning = readRecord(service.publicCopy?.remoteWarning);
1232
+ if (!warning)
1233
+ return null;
1234
+ const title = readString(warning.titleEs) ?? readString(warning.title);
1235
+ const body = readString(warning.bodyEs) ?? readString(warning.body);
1236
+ if (!title || !body)
1237
+ return null;
1238
+ return {
1239
+ title,
1240
+ body,
1241
+ linkHref: readString(warning.linkHref) ?? undefined,
1242
+ linkLabel: readString(warning.linkLabelEs) ?? readString(warning.linkLabel) ?? undefined,
1243
+ };
1244
+ }
1245
+ function readRecord(value) {
1246
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1247
+ return null;
1248
+ return value;
1249
+ }
1250
+ function readString(value) {
1251
+ return typeof value === 'string' && value.trim() ? value : null;
453
1252
  }
454
1253
  function BookingWidgetSkeleton() {
455
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bw-header", children: [_jsx("div", { className: "bw-skel bw-skel--title" }), _jsx("div", { className: "bw-skel bw-skel--text bw-skel--text-lg" })] }), _jsxs("div", { className: "bw-body bw-skel-desktop", children: [_jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel-list", children: Array.from({ length: 5 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--card" }, index))) })] }), _jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--calendar" }), _jsx("div", { className: "bw-skel-slots", children: _jsx("div", { className: "bw-skel-slots-grid", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }) })] }), _jsxs("div", { className: "bw-col", children: [_jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--textarea" })] })] }), _jsxs("div", { className: "bw-skel-mobile", children: [_jsx("div", { className: "bw-skel bw-skel--title" }), _jsx("div", { className: "bw-skel bw-skel--input" }), _jsx("div", { className: "bw-skel bw-skel--card" }), _jsx("div", { className: "bw-skel bw-skel--card" }), _jsx("div", { className: "bw-skel bw-skel--card" })] })] }));
1254
+ // Mirrors the live widget's DOM at first load so the chrome
1255
+ // doesn't reflow when real content arrives. Three-column body on
1256
+ // desktop (services / calendar / slots), single-column on mobile.
1257
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "bw-header", children: _jsx("div", { className: "bw-skel bw-skel--title" }) }), _jsx("div", { className: "bw-content", children: _jsxs("div", { className: "bw-body bw-skel-body", children: [_jsx("div", { className: "bw-col bw-col--left", children: _jsx("div", { className: "bw-step-1", children: _jsxs("div", { className: "bw-service-picker", children: [_jsx("div", { className: "bw-skel bw-skel--label" }), [
1258
+ { rows: 1 },
1259
+ { rows: 2 },
1260
+ { rows: 1 },
1261
+ ].map((group, groupIndex) => (_jsxs("div", { className: "bw-skel-svc-group", children: [_jsx("div", { className: "bw-skel bw-skel--svc-category" }), Array.from({ length: group.rows }).map((_, rowIndex) => (_jsxs("div", { className: "bw-skel-svc-row", children: [_jsx("div", { className: "bw-skel bw-skel--svc-image" }), _jsxs("div", { className: "bw-skel-svc-info", children: [_jsx("div", { className: "bw-skel bw-skel--svc-name" }), _jsx("div", { className: "bw-skel bw-skel--svc-desc" }), _jsx("div", { className: "bw-skel bw-skel--svc-meta" })] })] }, rowIndex)))] }, groupIndex)))] }) }) }), _jsx("div", { className: "bw-col bw-col--center", children: _jsxs("div", { className: "bw-cal-card bw-skel-cal-card", children: [_jsxs("div", { className: "bw-skel-cal-header", children: [_jsx("div", { className: "bw-skel bw-skel--cal-month" }), _jsxs("div", { className: "bw-skel-cal-navs", children: [_jsx("div", { className: "bw-skel bw-skel--cal-nav" }), _jsx("div", { className: "bw-skel bw-skel--cal-nav" })] })] }), _jsx("div", { className: "bw-skel-cal-dows", children: Array.from({ length: 7 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-dow" }, index))) }), _jsx("div", { className: "bw-skel-cal-grid", children: Array.from({ length: 42 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--cal-day" }, index))) }), _jsx("div", { className: "bw-skel bw-skel--cal-tz" })] }) }), _jsx("div", { className: "bw-col bw-col--right", children: _jsxs("div", { className: "bw-pane--slots", children: [_jsx("div", { className: "bw-skel bw-skel--slots-heading" }), _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))) })] }) })] }) })] }));
456
1262
  }
457
1263
  function AvailabilitySkeleton() {
458
- return (_jsx("div", { className: "bw-skel-slots", children: _jsx("div", { className: "bw-skel-slots-grid", children: Array.from({ length: 8 }).map((_, index) => (_jsx("div", { className: "bw-skel bw-skel--slot" }, index))) }) }));
1264
+ // Reuses .bw-time-slots so the skeleton inherits the same layout
1265
+ // overrides as the real slots — vertical 1-col on desktop (via the
1266
+ // .bw-slots-desktop scope), 4-col grid on mobile (default). Pill
1267
+ // dimensions match the rendered .bw-slot.
1268
+ 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))) }));
1269
+ }
1270
+ /**
1271
+ * Compact "Wed, May 6 · 3:00 PM – 5:00 PM" label used in reschedule
1272
+ * summary's "Former time" row. Uses local timezone of the browser;
1273
+ * the backend stores absolute ms timestamps so this renders correctly
1274
+ * for the host's calendar.
1275
+ */
1276
+ function formatFormerSlot(startMs, endMs) {
1277
+ const start = new Date(startMs);
1278
+ const end = new Date(endMs);
1279
+ const dateLabel = start.toLocaleDateString([], {
1280
+ weekday: 'short',
1281
+ month: 'short',
1282
+ day: 'numeric',
1283
+ });
1284
+ const timeOpts = {
1285
+ hour: 'numeric',
1286
+ minute: '2-digit',
1287
+ };
1288
+ return `${dateLabel} · ${start.toLocaleTimeString([], timeOpts)} – ${end.toLocaleTimeString([], timeOpts)}`;
1289
+ }
1290
+ /**
1291
+ * Possessive form of a name. "Eric Lan" → "Eric Lan's", "James" →
1292
+ * "James'". Splits on the full string's last char so single-name
1293
+ * customers (and last-name-only handles, etc.) still resolve cleanly.
1294
+ */
1295
+ function formatPossessive(name) {
1296
+ const trimmed = name.trim();
1297
+ if (!trimmed)
1298
+ return trimmed;
1299
+ const last = trimmed[trimmed.length - 1];
1300
+ if (last === 's' || last === 'S')
1301
+ return `${trimmed}'`;
1302
+ return `${trimmed}'s`;
459
1303
  }
460
1304
  function mobileStepTitle(step) {
461
1305
  switch (step) {
462
1306
  case 1:
463
- return 'Schedule your visit';
1307
+ return 'Choose a service';
464
1308
  case 2:
465
- return 'Select your service';
1309
+ return 'Pick a provider';
466
1310
  case 3:
467
1311
  return 'Pick a date & time';
468
1312
  case 4:
@@ -473,7 +1317,8 @@ function mobileStepTitle(step) {
473
1317
  }
474
1318
  function formatDuration(minutes) {
475
1319
  if (minutes >= 60 && minutes % 60 === 0) {
476
- return `${minutes / 60} hr`;
1320
+ const hours = minutes / 60;
1321
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
477
1322
  }
478
1323
  return `${minutes} min`;
479
1324
  }
@@ -633,10 +1478,22 @@ function ArrowRightIcon() {
633
1478
  function CheckIcon() {
634
1479
  return iconPath('M20 6 9 17l-5-5');
635
1480
  }
1481
+ function XIcon() {
1482
+ return iconPath('M18 6 6 18M6 6l12 12');
1483
+ }
1484
+ function WarningIcon() {
1485
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" }), _jsx("path", { d: "M12 9v4" }), _jsx("path", { d: "M12 17h.01" })] }));
1486
+ }
636
1487
  function UserIcon() {
637
1488
  return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
638
1489
  }
639
1490
  function GlobeIcon() {
640
1491
  return iconPath('M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 0c2.5 2.7 4 6.2 4 10s-1.5 7.3-4 10c-2.5-2.7-4-6.2-4-10s1.5-7.3 4-10ZM2 12h20');
641
1492
  }
1493
+ function CalendarIcon() {
1494
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "4", width: "18", height: "18", rx: "2" }), _jsx("path", { d: "M16 2v4M8 2v4M3 10h18" })] }));
1495
+ }
1496
+ function ClockIcon() {
1497
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("path", { d: "M12 6v6l4 2" })] }));
1498
+ }
642
1499
  //# sourceMappingURL=booking-widget.js.map