@asksable/site-connector 0.6.4 → 0.6.6

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.
@@ -11,29 +11,31 @@ import './bones/registry.js';
11
11
  import { BookingWidgetPlaceholder } from './booking-widget-placeholder.js';
12
12
  import { useSableSiteAnalytics, useSableSiteClient, useSableSiteConfig, useTranslation, } from './provider.js';
13
13
  import { DEFAULT_LOCALE, localeToIntl, pickLocaleField, } from './translations.js';
14
- const FLEXIBLE_WINDOW_OPTIONS = [
14
+ const FLEXIBLE_WINDOW_TEMPLATES = [
15
15
  {
16
16
  id: 'all-day',
17
- startTime: '09:00',
18
- endTime: '17:00',
19
17
  labelKey: 'flexWindowAllDay',
20
18
  descriptionKey: 'flexWindowAllDayDesc',
21
19
  },
22
20
  {
23
21
  id: 'morning',
24
- startTime: '09:00',
25
- endTime: '12:00',
26
22
  labelKey: 'flexWindowMorning',
27
23
  descriptionKey: 'flexWindowMorningDesc',
28
24
  },
29
25
  {
30
26
  id: 'afternoon',
31
- startTime: '12:00',
32
- endTime: '17:00',
33
27
  labelKey: 'flexWindowAfternoon',
34
28
  descriptionKey: 'flexWindowAfternoonDesc',
35
29
  },
36
30
  ];
31
+ const DEFAULT_FLEXIBLE_OPEN_TIME = '09:00';
32
+ const DEFAULT_FLEXIBLE_CLOSE_TIME = '17:00';
33
+ const NOON_MINUTES = 12 * 60;
34
+ const MIN_FLEXIBLE_WINDOW_MINUTES = 30;
35
+ const MAX_BOOKING_PHOTOS = 5;
36
+ const MAX_BOOKING_PHOTO_BASE64_LENGTH = 1_500_000;
37
+ const MAX_BOOKING_PHOTO_EDGE = 1600;
38
+ const BOOKING_PHOTO_JPEG_QUALITY = 0.78;
37
39
  const stripePromises = new Map();
38
40
  function getStripePromise(publishableKey, connectAccountId) {
39
41
  const key = `${publishableKey}:${connectAccountId}`;
@@ -95,7 +97,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
95
97
  const [selectedDate, setSelectedDate] = useState(null);
96
98
  const [selectedSlot, setSelectedSlot] = useState(null);
97
99
  const [schedulingPreference, setSchedulingPreference] = useState(() => defaultSchedulingPreference === 'flexible' ? 'flexible' : 'exact');
98
- const [selectedFlexibleWindowId, setSelectedFlexibleWindowId] = useState(() => FLEXIBLE_WINDOW_OPTIONS[0]?.id ?? 'all-day');
100
+ const [selectedFlexibleWindowId, setSelectedFlexibleWindowId] = useState('all-day');
99
101
  const [pendingSlotKey, setPendingSlotKey] = useState(null);
100
102
  // Service picker is progressive: full list shows initially, collapses
101
103
  // to a single "selected service" card once a service is picked, then
@@ -113,7 +115,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
113
115
  const [customerName, setCustomerName] = useState(() => rescheduleContext?.customerName ?? '');
114
116
  const [customerEmail, setCustomerEmail] = useState(() => rescheduleContext?.customerEmail ?? '');
115
117
  const [customerPhone, setCustomerPhone] = useState(() => rescheduleContext?.customerPhone ?? '');
118
+ const [serviceAddress, setServiceAddress] = useState('');
116
119
  const [customerNotes, setCustomerNotes] = useState('');
120
+ const [bookingPhotos, setBookingPhotos] = useState([]);
121
+ const [isPreparingPhotos, setIsPreparingPhotos] = useState(false);
122
+ const [photoError, setPhotoError] = useState(null);
117
123
  const [intakeResponses, setIntakeResponses] = useState(() => initialSelection?.intakeResponses ?? {});
118
124
  const initialSelectionAppliedRef = useRef(false);
119
125
  const previousSelectedServiceIdRef = useRef(selectedServiceId);
@@ -273,16 +279,34 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
273
279
  ? quoteFromOverride(selectedService, selectedQuoteOverride)
274
280
  : quote;
275
281
  const isDisplayQuoteLoading = isQuoteLoading && !displayQuote;
282
+ const bookingContextRows = useMemo(() => buildBookingContextRows({
283
+ intakeResponses,
284
+ quote: displayQuote,
285
+ locale: intlLocale,
286
+ }), [displayQuote, intakeResponses, intlLocale]);
276
287
  const selectedServiceRequiresSlot = selectedService?.publicBookingMode !== 'async';
277
288
  const flexibleSchedulingEnabled = allowFlexibleScheduling && selectedServiceRequiresSlot && !isReschedule;
278
289
  const effectiveSchedulingPreference = flexibleSchedulingEnabled ? schedulingPreference : 'exact';
279
- const selectedFlexibleWindow = FLEXIBLE_WINDOW_OPTIONS.find((option) => option.id === selectedFlexibleWindowId) ?? FLEXIBLE_WINDOW_OPTIONS[0];
280
290
  const bookingTimezone = resolveBookingTimezone({
281
291
  workspaceTimezone: setup?.workspaceTimezone,
282
292
  configuredTimezone,
283
293
  fallbackStaffTimezone: setup?.staff?.[0]?.timezone,
284
294
  });
285
295
  const todayDateKey = dateKeyInTimeZone(Date.now(), bookingTimezone);
296
+ const selectedDateFlexibleWindows = useMemo(() => selectedDate
297
+ ? getFlexibleWindowOptionsForDate({
298
+ dateKey: selectedDate,
299
+ businessHours: setup?.businessHours ?? [],
300
+ }).filter((option) => isFlexibleWindowSelectable({
301
+ dateKey: selectedDate,
302
+ service: selectedService,
303
+ timezone: bookingTimezone,
304
+ window: option,
305
+ }))
306
+ : [], [bookingTimezone, selectedDate, selectedService, setup?.businessHours]);
307
+ const selectedFlexibleWindow = selectedDateFlexibleWindows.find((option) => option.id === selectedFlexibleWindowId) ??
308
+ selectedDateFlexibleWindows[0] ??
309
+ null;
286
310
  const selectedServicePath = selectedServiceRequiresSlot
287
311
  ? 'scheduled'
288
312
  : 'async';
@@ -290,10 +314,28 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
290
314
  const isIntakeComplete = useMemo(() => !selectedService?.intakeForm ||
291
315
  areRequiredIntakeFieldsComplete(selectedService.intakeForm, intakeResponses, selectedServicePath), [intakeResponses, selectedService?.intakeForm, selectedServicePath]);
292
316
  const serviceWarning = useMemo(() => getServiceWarning(selectedService, selectedServicePath, locale), [selectedService, selectedServicePath, locale]);
317
+ useEffect(() => {
318
+ if (effectiveSchedulingPreference !== 'flexible')
319
+ return;
320
+ if (selectedDateFlexibleWindows.length === 0)
321
+ return;
322
+ if (selectedDateFlexibleWindows.some((option) => option.id === selectedFlexibleWindowId)) {
323
+ return;
324
+ }
325
+ setSelectedFlexibleWindowId(selectedDateFlexibleWindows[0].id);
326
+ }, [
327
+ effectiveSchedulingPreference,
328
+ selectedDateFlexibleWindows,
329
+ selectedFlexibleWindowId,
330
+ ]);
293
331
  const selectedServiceRequiresPayment = selectedService?.requiresPayment === true;
294
332
  const trimmedCustomerEmail = customerEmail.trim();
333
+ const trimmedCustomerPhone = customerPhone.trim();
334
+ const trimmedServiceAddress = serviceAddress.trim();
295
335
  const isCustomerEmailValid = trimmedCustomerEmail.length > 0 && isValidEmailAddress(trimmedCustomerEmail);
296
336
  const showCustomerEmailError = trimmedCustomerEmail.length > 0 && !isCustomerEmailValid;
337
+ const showCustomerPhoneError = submitAttempted && !trimmedCustomerPhone;
338
+ const showServiceAddressError = submitAttempted && !isReschedule && !trimmedServiceAddress;
297
339
  // In reschedule mode we pre-select the original service but keep
298
340
  // the full list visible - the admin may legitimately need to
299
341
  // switch service when rescheduling (e.g. customer asked for a
@@ -395,15 +437,24 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
395
437
  if (effectiveSchedulingPreference === 'flexible') {
396
438
  setAvailabilityByDate(new Map());
397
439
  setIsAvailabilityLoading(false);
398
- if (selectedDate &&
399
- !isFlexibleDateSelectable({
400
- dateKey: selectedDate,
401
- service: selectedService,
402
- businessHours: setup?.businessHours ?? [],
403
- timezone: bookingTimezone,
404
- window: selectedFlexibleWindow,
405
- })) {
406
- setSelectedDate(null);
440
+ const firstAvailableDate = findFirstSelectableFlexibleDate({
441
+ calendarMonth,
442
+ todayDateKey,
443
+ service: selectedService,
444
+ businessHours: setup?.businessHours ?? [],
445
+ timezone: bookingTimezone,
446
+ });
447
+ if (!selectedDate) {
448
+ setSelectedDate(firstAvailableDate);
449
+ return;
450
+ }
451
+ if (!hasSelectableFlexibleWindowsForDate({
452
+ dateKey: selectedDate,
453
+ service: selectedService,
454
+ businessHours: setup?.businessHours ?? [],
455
+ timezone: bookingTimezone,
456
+ })) {
457
+ setSelectedDate(firstAvailableDate);
407
458
  }
408
459
  return;
409
460
  }
@@ -483,10 +534,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
483
534
  selectedService?.publicBookingMode,
484
535
  selectedServiceId,
485
536
  selectedStaffId,
486
- selectedFlexibleWindow,
487
537
  selectedService,
488
538
  setup?.businessHours,
489
539
  siteSlug,
540
+ todayDateKey,
490
541
  ]);
491
542
  useEffect(() => {
492
543
  if (!selectedServiceId ||
@@ -756,7 +807,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
756
807
  (effectiveSchedulingPreference === 'flexible'
757
808
  ? Boolean(selectedDate && selectedFlexibleWindow)
758
809
  : Boolean(selectedDate && selectedSlot && holdId));
759
- const selectedTimeLabel = selectedDate && effectiveSchedulingPreference === 'flexible'
810
+ const selectedTimeLabel = selectedDate &&
811
+ effectiveSchedulingPreference === 'flexible' &&
812
+ selectedFlexibleWindow
760
813
  ? formatFlexibleWindowTimeRange(selectedFlexibleWindow, intlLocale)
761
814
  : selectedSlot
762
815
  ? `${formatTimeLabel(selectedSlot.time, intlLocale)} – ${formatTimeLabel(selectedSlot.endTime, intlLocale)}`
@@ -765,6 +818,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
765
818
  const canAdvanceStep3 = Boolean(selectedService &&
766
819
  customerName.trim() &&
767
820
  isCustomerEmailValid &&
821
+ (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress)) &&
768
822
  isIntakeComplete);
769
823
  // Slots block is rendered in two locations: inline under the calendar
770
824
  // (mobile flow keeps current step-3 behavior) and at the top of the
@@ -793,10 +847,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
793
847
  })] })) : null;
794
848
  const schedulingPreferenceControl = flexibleSchedulingEnabled ? (_jsxs("div", { className: "bw-schedule-mode", role: "radiogroup", "aria-label": t('scheduleModeAria'), children: [_jsx("button", { type: "button", role: "radio", "aria-checked": effectiveSchedulingPreference === 'flexible', className: `bw-schedule-mode-btn${effectiveSchedulingPreference === 'flexible' ? ' is-active' : ''}`, onClick: () => handleSchedulingPreferenceChange('flexible'), children: _jsx("span", { children: t('scheduleModeFlexible') }) }), _jsx("button", { type: "button", role: "radio", "aria-checked": effectiveSchedulingPreference === 'exact', className: `bw-schedule-mode-btn${effectiveSchedulingPreference === 'exact' ? ' is-active' : ''}`, onClick: () => handleSchedulingPreferenceChange('exact'), children: _jsx("span", { children: t('scheduleModeExact') }) })] })) : null;
795
849
  const slotsAreaKey = `${selectedServiceId ?? 'none'}-${selectedStaffId ?? 'any'}-${selectedDate ?? 'none'}-${effectiveSchedulingPreference}-${selectedFlexibleWindowId}-${isAvailabilityLoading ? 'loading' : 'ready'}`;
796
- const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : effectiveSchedulingPreference === 'flexible' ? (selectedDate ? (_jsx("div", { className: "bw-window-options", children: FLEXIBLE_WINDOW_OPTIONS.map((option, index) => {
797
- const isActive = selectedFlexibleWindowId === option.id;
798
- return (_jsxs("button", { type: "button", className: `bw-window-option${isActive ? ' is-active' : ''}`, style: { '--bw-slot-i': index }, onClick: () => handleFlexibleWindowSelect(option.id), children: [_jsxs("span", { className: "bw-window-option-main", children: [_jsx("span", { children: t(option.labelKey) }), _jsx("span", { children: formatFlexibleWindowTimeRange(option, intlLocale) })] }), _jsx("span", { className: "bw-window-option-desc", children: t(option.descriptionKey) })] }, option.id));
799
- }) })) : (_jsx("p", { className: "bw-no-slots", children: t('flexPickDateFirst') }))) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
850
+ const slotsArea = (_jsx("div", { className: "bw-slots-fade", children: !selectedService ? (_jsx("p", { className: "bw-slots-empty", children: t('slotsSelectServicePrompt') })) : effectiveSchedulingPreference === 'flexible' ? (selectedDate ? (_jsxs("div", { className: "bw-window-options", children: [selectedDateFlexibleWindows.map((option, index) => {
851
+ const isActive = selectedFlexibleWindowId === option.id;
852
+ return (_jsxs("button", { type: "button", className: `bw-window-option${isActive ? ' is-active' : ''}`, style: { '--bw-slot-i': index }, onClick: () => handleFlexibleWindowSelect(option.id), children: [_jsxs("span", { className: "bw-window-option-main", children: [_jsx("span", { children: t(option.labelKey) }), _jsx("span", { children: formatFlexibleWindowTimeRange(option, intlLocale) })] }), _jsx("span", { className: "bw-window-option-desc", children: t(option.descriptionKey) })] }, option.id));
853
+ }), selectedDateFlexibleWindows.length === 0 ? (_jsx("p", { className: "bw-no-slots", children: t('slotsEmptyForDate') })) : null] })) : (_jsx("p", { className: "bw-no-slots", children: t('flexPickDateFirst') }))) : isAvailabilityLoading ? (_jsx(AvailabilitySkeleton, {})) : selectedDate ? (selectedDateSlots.length > 0 ? (_jsx("div", { className: "bw-time-slots", children: selectedDateSlots.map((slot, index) => {
800
854
  const slotKey = `${slot.time}-${slot.endTime}`;
801
855
  const isPending = pendingSlotKey === slotKey;
802
856
  const isActive = isPending ||
@@ -811,7 +865,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
811
865
  isIntakeComplete &&
812
866
  (selectedService.pricingMode !== 'calculated' || quote) &&
813
867
  customerName.trim() &&
814
- isCustomerEmailValid) && !isSubmitting;
868
+ isCustomerEmailValid &&
869
+ (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
815
870
  const submitBlockers = selectedService
816
871
  ? getSubmitBlockers({
817
872
  selectedService,
@@ -823,6 +878,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
823
878
  quote,
824
879
  customerName,
825
880
  customerEmail,
881
+ customerPhone,
882
+ serviceAddress,
883
+ requireServiceAddress: !isReschedule,
826
884
  t,
827
885
  locale,
828
886
  })
@@ -830,9 +888,47 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
830
888
  function handleNotesInput(event) {
831
889
  setCustomerNotes(event.currentTarget.value);
832
890
  }
891
+ async function handlePhotoInput(event) {
892
+ const files = Array.from(event.currentTarget.files ?? []);
893
+ event.currentTarget.value = '';
894
+ if (files.length === 0)
895
+ return;
896
+ setPhotoError(null);
897
+ setIsPreparingPhotos(true);
898
+ try {
899
+ const remaining = Math.max(0, MAX_BOOKING_PHOTOS - bookingPhotos.length);
900
+ const accepted = files.slice(0, remaining);
901
+ const prepared = [];
902
+ for (const file of accepted) {
903
+ prepared.push(await prepareBookingPhoto(file));
904
+ }
905
+ setBookingPhotos((current) => [...current, ...prepared].slice(0, MAX_BOOKING_PHOTOS));
906
+ if (files.length > remaining) {
907
+ setPhotoError(t('photosLimit', { count: MAX_BOOKING_PHOTOS }));
908
+ }
909
+ }
910
+ catch (nextError) {
911
+ setPhotoError(nextError instanceof Error ? nextError.message : t('photosFailed'));
912
+ }
913
+ finally {
914
+ setIsPreparingPhotos(false);
915
+ }
916
+ }
833
917
  function renderNotesField(id, label, placeholder) {
834
918
  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"] })] }));
835
919
  }
920
+ function renderBookingContext() {
921
+ if (bookingContextRows.length === 0)
922
+ return null;
923
+ return (_jsxs("div", { className: "bw-booking-context", children: [_jsx("span", { className: "bw-booking-context-title", children: t('bookingContextTitle') }), _jsx("div", { className: "bw-booking-context-rows", children: bookingContextRows.map((row) => (_jsxs("div", { className: "bw-booking-context-row", children: [_jsx("span", { children: row.label }), _jsx("span", { children: row.value })] }, row.key))) })] }));
924
+ }
925
+ function renderPhotoField(id) {
926
+ if (isReschedule)
927
+ return null;
928
+ return (_jsxs("div", { className: "bw-photo-field", children: [_jsxs("div", { className: "bw-photo-head", children: [_jsxs("div", { children: [_jsx("span", { className: "bw-photo-title", children: t('photosLabel') }), _jsx("p", { children: t('photosHelp') })] }), _jsx("label", { className: "bw-photo-button", htmlFor: id, children: isPreparingPhotos ? t('photosPreparing') : t('photosAdd') }), _jsx("input", { id: id, type: "file", accept: "image/*", multiple: true, onChange: (event) => void handlePhotoInput(event), disabled: isPreparingPhotos || bookingPhotos.length >= MAX_BOOKING_PHOTOS })] }), photoError ? _jsx("p", { className: "bw-photo-error", children: photoError }) : null, bookingPhotos.length > 0 ? (_jsx("div", { className: "bw-photo-list", children: bookingPhotos.map((photo) => (_jsxs("div", { className: "bw-photo-item", children: [_jsx("span", { children: photo.filename }), _jsx("button", { type: "button", onClick: () => setBookingPhotos((current) => current.filter((item) => item.id !== photo.id)), "aria-label": t('photosRemoveAria', {
929
+ name: photo.filename,
930
+ }), children: t('photosRemove') })] }, photo.id))) })) : null] }));
931
+ }
836
932
  function renderSubmitHelp() {
837
933
  if (!selectedService || canSubmit || payment || isSubmitting)
838
934
  return null;
@@ -852,8 +948,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
852
948
  const nameId = `bw-name${idSuffix}`;
853
949
  const emailId = `bw-email${idSuffix}`;
854
950
  const phoneId = `bw-phone${idSuffix}`;
951
+ const addressId = `bw-service-address${idSuffix}`;
855
952
  const emailErrorId = `${emailId}-error`;
856
- return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: [t('contactFullName'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: t('placeholderFullName') })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: [t('contactEmail'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: t('placeholderEmail') }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: t('contactEmailInvalid') })) : null] }), _jsxs("div", { className: "bw-field", children: [_jsx("label", { htmlFor: phoneId, children: t('contactPhone') }), _jsx("input", { id: phoneId, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), placeholder: t('placeholderPhone') })] })] }));
953
+ const phoneErrorId = `${phoneId}-error`;
954
+ const addressErrorId = `${addressId}-error`;
955
+ return (_jsxs("div", { className: "bw-form-fields", children: [_jsxs("div", { className: "bw-field", children: [_jsxs("label", { htmlFor: nameId, children: [t('contactFullName'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: nameId, value: customerName, onChange: (event) => setCustomerName(event.target.value), placeholder: t('placeholderFullName') })] }), _jsxs("div", { className: `bw-field${showCustomerEmailError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: emailId, children: [t('contactEmail'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: emailId, type: "email", required: true, value: customerEmail, onChange: (event) => setCustomerEmail(event.target.value), "aria-invalid": showCustomerEmailError, "aria-describedby": showCustomerEmailError ? emailErrorId : undefined, placeholder: t('placeholderEmail') }), showCustomerEmailError ? (_jsx("span", { className: "bw-field-error", id: emailErrorId, children: t('contactEmailInvalid') })) : null] }), _jsxs("div", { className: `bw-field${showCustomerPhoneError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: phoneId, children: [t('contactPhone'), " ", _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: phoneId, type: "tel", required: true, value: customerPhone, onChange: (event) => setCustomerPhone(event.target.value), "aria-invalid": showCustomerPhoneError, "aria-describedby": showCustomerPhoneError ? phoneErrorId : undefined, placeholder: t('placeholderPhone') }), showCustomerPhoneError ? (_jsx("span", { className: "bw-field-error", id: phoneErrorId, children: t('contactPhoneRequired') })) : null] }), !isReschedule ? (_jsxs("div", { className: `bw-field bw-field--wide${showServiceAddressError ? ' is-invalid' : ''}`, children: [_jsxs("label", { htmlFor: addressId, children: [t('contactServiceAddress'), ' ', _jsx("span", { className: "bw-required", children: "*" })] }), _jsx("input", { id: addressId, required: true, value: serviceAddress, onChange: (event) => setServiceAddress(event.target.value), "aria-invalid": showServiceAddressError, "aria-describedby": showServiceAddressError ? addressErrorId : undefined, placeholder: t('placeholderServiceAddress') }), showServiceAddressError ? (_jsx("span", { className: "bw-field-error", id: addressErrorId, children: t('contactServiceAddressRequired') })) : null] })) : null] }));
857
956
  }
858
957
  function renderIntakeFields(idPrefix) {
859
958
  return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
@@ -1045,16 +1144,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1045
1144
  : selectedDate && selectedSlot
1046
1145
  ? zonedDateTimeToTimestamp(selectedDate, selectedSlot.endTime, bookingTimezone)
1047
1146
  : newStartTime + selectedService.durationMinutes * 60 * 1000;
1147
+ const bookingBaseNotes = formatBookingNotesForSubmit({
1148
+ contextRows: bookingContextRows,
1149
+ additionalNotes: customerNotes,
1150
+ t,
1151
+ });
1048
1152
  const bookingNotes = isFlexibleRequest
1049
1153
  ? appendFlexibleWindowNote({
1050
- notes: customerNotes,
1154
+ notes: bookingBaseNotes,
1051
1155
  dateKey: selectedDate,
1052
1156
  window: selectedFlexibleWindow,
1053
1157
  locale: intlLocale,
1054
1158
  timezone: bookingTimezone,
1055
1159
  t,
1056
1160
  })
1057
- : customerNotes.trim() || undefined;
1161
+ : bookingBaseNotes || undefined;
1058
1162
  // Reschedule mode bypasses the public-create flow entirely.
1059
1163
  // Service / staff / customer are already attached to the
1060
1164
  // original appointment, so the host (admin path) or magic-link
@@ -1101,9 +1205,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1101
1205
  deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
1102
1206
  customerName: customerName.trim(),
1103
1207
  customerEmail: customerEmail.trim(),
1104
- customerPhone: customerPhone.trim() || undefined,
1208
+ customerPhone: trimmedCustomerPhone,
1105
1209
  customerNotes: bookingNotes,
1210
+ location: trimmedServiceAddress,
1106
1211
  intakeResponses,
1212
+ photos: bookingPhotos.map((photo) => ({
1213
+ filename: photo.filename,
1214
+ data: photo.data,
1215
+ })),
1107
1216
  quotedTotalCents: displayQuote?.totalCents,
1108
1217
  bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
1109
1218
  ? holdId
@@ -1145,7 +1254,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1145
1254
  setCustomerName('');
1146
1255
  setCustomerEmail('');
1147
1256
  setCustomerPhone('');
1257
+ setServiceAddress('');
1148
1258
  setCustomerNotes('');
1259
+ setBookingPhotos([]);
1260
+ setPhotoError(null);
1149
1261
  setIntakeResponses({});
1150
1262
  setQuote(null);
1151
1263
  setPayment(null);
@@ -1290,12 +1402,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1290
1402
  const isExactAvailable = slots.length > 0;
1291
1403
  const isFlexibleAvailable = effectiveSchedulingPreference === 'flexible' &&
1292
1404
  selectedService != null &&
1293
- isFlexibleDateSelectable({
1405
+ hasSelectableFlexibleWindowsForDate({
1294
1406
  dateKey,
1295
1407
  service: selectedService,
1296
1408
  businessHours: setup?.businessHours ?? [],
1297
1409
  timezone: bookingTimezone,
1298
- window: selectedFlexibleWindow,
1299
1410
  });
1300
1411
  const isAvailable = effectiveSchedulingPreference === 'flexible'
1301
1412
  ? isFlexibleAvailable
@@ -1352,18 +1463,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1352
1463
  }, children: formerStaffName ?? t('providerAny') })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryService') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: pickLocaleField(selectedService, 'name', locale) ??
1353
1464
  selectedService.name })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryProvider') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedHeldStaff?.name ??
1354
1465
  selectedStaff?.name ??
1355
- t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] })] })] }), (() => {
1466
+ t('providerAny') })] }), selectedDate ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDate') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatReadableDate(selectedDate, intlLocale) })] })) : null, selectedTimeLabel ? (_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryTime') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: selectedTimeLabel })] })) : null, _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('summaryDuration') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatDuration(selectedService.durationMinutes) })] })] })] }), _jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewYourDetails') }), _jsxs("div", { className: "bw-summary-rows", children: [_jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactFullName') }), _jsx("span", { className: "bw-summary-val", children: customerName.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactEmail') }), _jsx("span", { className: "bw-summary-val", children: customerEmail.trim() || t('reviewNotProvided') })] }), _jsxs("div", { className: "bw-summary-row", children: [_jsx("span", { children: t('contactPhone') }), _jsx("span", { className: "bw-summary-val", children: customerPhone.trim() || t('reviewNotProvided') })] }), !isReschedule ? (_jsxs("div", { className: "bw-summary-row bw-summary-row--stack", children: [_jsx("span", { children: t('contactServiceAddress') }), _jsx("span", { className: "bw-summary-val", children: serviceAddress.trim() || t('reviewNotProvided') })] })) : null] })] }), (() => {
1356
1467
  const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
1357
1468
  if (intakeRows.length === 0)
1358
1469
  return null;
1359
1470
  return (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('reviewAdditionalInfo') }), _jsx("div", { className: "bw-summary-rows", children: intakeRows.map((row) => (_jsxs("div", { className: `bw-summary-row${row.multiline ? ' bw-summary-row--stack' : ''}`, children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] }));
1360
- })(), customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
1471
+ })(), bookingContextRows.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('bookingContextTitle') }), _jsx("div", { className: "bw-summary-rows", children: bookingContextRows.map((row) => (_jsxs("div", { className: "bw-summary-row bw-summary-row--stack", children: [_jsx("span", { children: row.label }), _jsx("span", { className: "bw-summary-val", children: row.value })] }, row.key))) })] })) : null, customerNotes.trim() ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: isReschedule
1361
1472
  ? t('notesLabelReschedule')
1362
- : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
1473
+ : t('reviewNotesSection') }), _jsx("p", { className: "bw-summary-notes", children: customerNotes.trim() })] })) : null, !isReschedule && bookingPhotos.length > 0 ? (_jsxs("div", { className: "bw-summary-group", children: [_jsx("span", { className: "bw-summary-subhead", children: t('photosLabel') }), _jsx("p", { className: "bw-summary-notes", children: t('photosCount', { count: bookingPhotos.length }) })] })) : null, _jsx("div", { className: "bw-summary-rows bw-summary-rows--total", children: _jsxs("div", { className: "bw-summary-row bw-summary-total", children: [_jsx("span", { children: isDisplayQuoteLoading
1363
1474
  ? t('summaryCalculating')
1364
1475
  : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-summary-val", style: summaryValStyle, children: formatServicePrice(selectedService, displayQuote, intlLocale) })] }) })] })) : null }), _jsx("div", { className: "bw-col bw-col--right", children: _jsx("div", { className: "bw-right-scroll", children: _jsx("div", { className: "bw-right-inner", children: _jsxs("div", { className: "bw-right-stage", children: [_jsxs("div", { className: `bw-pane bw-pane--slots${viewState === 'slots' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'slots', children: [_jsxs("div", { className: "bw-slots-heading bw-slots-heading--sticky", children: [_jsx("span", { children: effectiveSchedulingPreference === 'flexible'
1365
1476
  ? t('flexWindowHeading')
1366
- : t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children: FLEXIBLE_WINDOW_OPTIONS.length })) : selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake', ''), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
1477
+ : t('slotsHeading') }), effectiveSchedulingPreference === 'flexible' ? (_jsx("span", { className: "bw-slots-count", children: selectedDateFlexibleWindows.length })) : selectedDateSlots.length > 0 ? (_jsx("span", { className: "bw-slots-count", children: selectedDateSlots.length })) : null] }), _jsx("div", { className: "bw-slots-desktop", children: slotsArea })] }), _jsxs("div", { className: `bw-pane bw-pane--details${viewState === 'details' ? ' is-active' : ' is-hidden'}`, "aria-hidden": viewState !== 'details', children: [selectedServiceRequiresSlot ? (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: () => void handleBackToSlots(), children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentTime') })] })) : (_jsxs("button", { type: "button", className: "bw-back-btn", onClick: handleBackToServicePicker, children: [_jsx(ArrowLeftIcon, {}), _jsx("span", { children: t('backToDifferentService') })] })), _jsxs("div", { className: "bw-form", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake', ''), renderBookingContext(), renderNotesField('bw-notes', t('notesLabel'), t('notesPlaceholder')), renderPhotoField('bw-photos'), selectedService ? (_jsxs("div", { className: "bw-summary", children: [_jsx("span", { className: "bw-summary-title", children: isReschedule
1367
1478
  ? t('summaryTitleReschedule')
1368
1479
  : t('summaryTitleCreate') }), _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: t('summaryReservationHold') }), _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: t('summaryFormerTime') }), _jsx("span", { className: "bw-summary-val", style: {
1369
1480
  ...summaryValStyle,
@@ -1398,11 +1509,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1398
1509
  ? t('summaryNewTime')
1399
1510
  : t('summaryWhen') }), _jsxs("span", { className: "bw-details-meta-value", children: [formatReadableDate(selectedDate, intlLocale), _jsx("br", {}), selectedTimeLabel] })] })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: formatDuration(selectedService.durationMinutes) })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(UserIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: selectedHeldStaff?.name ??
1400
1511
  selectedStaff?.name ??
1401
- t('providerAny') })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: bookingTimezone })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isDisplayQuoteLoading
1512
+ t('providerAny') })] }), !isReschedule && serviceAddress.trim() ? (_jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(LocationPinIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: serviceAddress.trim() })] })) : null, _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(GlobeIcon, {}) }), _jsx("span", { className: "bw-details-meta-value", children: bookingTimezone })] }), _jsxs("div", { className: "bw-details-meta-row", children: [_jsx("span", { className: "bw-details-meta-icon", "aria-hidden": "true", children: "$" }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: isDisplayQuoteLoading
1402
1513
  ? t('summaryCalculating')
1403
- : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
1514
+ : t('summaryEstimatedTotal') }), _jsx("span", { className: "bw-details-meta-value", children: formatServicePrice(selectedService, displayQuote, intlLocale) })] })] }), holdSecondsRemaining !== null ? (_jsxs("div", { className: "bw-details-meta-row bw-details-meta-row--hold", "aria-live": "polite", children: [_jsx("span", { className: "bw-details-meta-icon", children: _jsx(ClockIcon, {}) }), _jsxs("div", { className: "bw-details-meta-text", children: [_jsx("span", { className: "bw-details-meta-label", children: t('summaryReservationHold') }), _jsx("span", { className: "bw-details-meta-value bw-details-hold", children: formatHoldCountdown(holdSecondsRemaining) })] })] })) : null] })] })) : null }), _jsx("div", { className: "bw-details-form", children: _jsxs("div", { className: "bw-form bw-form--details", children: [serviceWarning ? (_jsx(ServiceWarningCallout, { warning: serviceWarning })) : null, renderIntakeAndContact('bw-intake-d', '-d'), renderBookingContext(), renderNotesField('bw-notes-d', isReschedule ? t('notesLabelReschedule') : t('notesLabel'), isReschedule
1404
1515
  ? t('notesPlaceholderReschedule')
1405
- : t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
1516
+ : t('notesPlaceholder')), renderPhotoField('bw-photos-d'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
1406
1517
  (forcedState === 'payment-full' ||
1407
1518
  forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
1408
1519
  setSuccess(t('successPaymentReceived'));
@@ -1512,7 +1623,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1512
1623
  return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1513
1624
  }));
1514
1625
  }
1515
- function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
1626
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
1516
1627
  const blockers = [];
1517
1628
  if (selectedServiceRequiresSlot && !hasScheduleSelection) {
1518
1629
  blockers.push(t('blockerDateTime'));
@@ -1525,6 +1636,11 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
1525
1636
  else if (!isValidEmailAddress(customerEmail)) {
1526
1637
  blockers.push(t('blockerContactEmailInvalid'));
1527
1638
  }
1639
+ if (!customerPhone.trim())
1640
+ blockers.push(t('blockerContactPhone'));
1641
+ if (requireServiceAddress && !serviceAddress.trim()) {
1642
+ blockers.push(t('blockerServiceAddress'));
1643
+ }
1528
1644
  if (!isIntakeComplete && selectedService.intakeForm) {
1529
1645
  blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1530
1646
  }
@@ -1671,6 +1787,123 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1671
1787
  }
1672
1788
  return rows;
1673
1789
  }
1790
+ function buildBookingContextRows({ intakeResponses, quote, locale, }) {
1791
+ const rows = [];
1792
+ const usedKeys = new Set();
1793
+ const addKnown = (keys, label, formatter = formatContextValue) => {
1794
+ for (const key of keys) {
1795
+ const value = intakeResponses[key];
1796
+ const formatted = formatter(value);
1797
+ if (formatted) {
1798
+ rows.push({ key, label, value: formatted });
1799
+ usedKeys.add(key);
1800
+ return;
1801
+ }
1802
+ }
1803
+ };
1804
+ addKnown(['vehicle_size', 'vehicleSize', 'vehicle', 'vehicle_type', 'vehicleType'], 'Vehicle', formatVehicleContextValue);
1805
+ addKnown(['pet_hair', 'petHair', 'pet_hair_level'], 'Pet hair');
1806
+ addKnown([
1807
+ 'stains',
1808
+ 'stain_removal',
1809
+ 'stainRemoval',
1810
+ 'shampooing',
1811
+ 'shampoo_needed',
1812
+ 'shampooNeeded',
1813
+ 'stains_or_shampooing',
1814
+ ], 'Stains or shampooing');
1815
+ const estimateCents = readCentsValue(intakeResponses.estimate_cents) ??
1816
+ readCentsValue(intakeResponses.estimateCents) ??
1817
+ quote?.totalCents ??
1818
+ null;
1819
+ if (estimateCents !== null) {
1820
+ rows.push({
1821
+ key: 'estimate_cents',
1822
+ label: 'Website estimate',
1823
+ value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
1824
+ });
1825
+ usedKeys.add('estimate_cents');
1826
+ usedKeys.add('estimateCents');
1827
+ }
1828
+ for (const [key, value] of Object.entries(intakeResponses)) {
1829
+ if (usedKeys.has(key))
1830
+ continue;
1831
+ if (key === 'estimate_label' || key === 'estimateLabel')
1832
+ continue;
1833
+ const formatted = formatContextValue(value);
1834
+ if (!formatted)
1835
+ continue;
1836
+ rows.push({
1837
+ key,
1838
+ label: formatContextKey(key),
1839
+ value: formatted,
1840
+ });
1841
+ if (rows.length >= 8)
1842
+ break;
1843
+ }
1844
+ return rows;
1845
+ }
1846
+ function formatVehicleContextValue(value) {
1847
+ const raw = formatContextValue(value);
1848
+ if (!raw)
1849
+ return null;
1850
+ const normalized = raw.toLowerCase().replace(/[\s_-]+/g, '-');
1851
+ const labels = {
1852
+ car: 'Car or small SUV',
1853
+ small: 'Car or small SUV',
1854
+ sedan: 'Car or small SUV',
1855
+ 'small-suv': 'Car or small SUV',
1856
+ large: 'Large or 3-row',
1857
+ '3-row': 'Large or 3-row',
1858
+ 'three-row': 'Large or 3-row',
1859
+ suv: 'Large or 3-row',
1860
+ van: 'Large or 3-row',
1861
+ minivan: 'Large or 3-row',
1862
+ truck: 'Large or 3-row',
1863
+ };
1864
+ return labels[normalized] ?? raw;
1865
+ }
1866
+ function formatContextValue(value) {
1867
+ if (typeof value === 'string') {
1868
+ const trimmed = value.trim();
1869
+ if (!trimmed)
1870
+ return null;
1871
+ return titleizeContextValue(trimmed);
1872
+ }
1873
+ if (typeof value === 'number' && Number.isFinite(value))
1874
+ return String(value);
1875
+ if (typeof value === 'boolean')
1876
+ return value ? 'Yes' : 'No';
1877
+ if (Array.isArray(value)) {
1878
+ const parts = value.map(formatContextValue).filter(Boolean);
1879
+ return parts.length ? parts.join(', ') : null;
1880
+ }
1881
+ return null;
1882
+ }
1883
+ function titleizeContextValue(value) {
1884
+ return value
1885
+ .replace(/[_-]+/g, ' ')
1886
+ .replace(/\s+/g, ' ')
1887
+ .trim()
1888
+ .replace(/\b\w/g, (char) => char.toUpperCase());
1889
+ }
1890
+ function formatContextKey(key) {
1891
+ return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
1892
+ }
1893
+ function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
1894
+ const sections = [];
1895
+ if (contextRows.length > 0) {
1896
+ sections.push([
1897
+ `${t('bookingContextTitle')}:`,
1898
+ ...contextRows.map((row) => `${row.label}: ${row.value}`),
1899
+ ].join('\n'));
1900
+ }
1901
+ const trimmed = additionalNotes.trim();
1902
+ if (trimmed) {
1903
+ sections.push(`${t('notesLabel')}:\n${trimmed}`);
1904
+ }
1905
+ return sections.join('\n\n');
1906
+ }
1674
1907
  function readFiniteNumber(value, fallback) {
1675
1908
  if (typeof value === 'number' && Number.isFinite(value))
1676
1909
  return value;
@@ -1983,7 +2216,8 @@ function readCentsValue(value) {
1983
2216
  function initialSelectionMatchesService(initialSelection, service) {
1984
2217
  if (!initialSelection)
1985
2218
  return false;
1986
- if (initialSelection.serviceId && initialSelection.serviceId === service._id) {
2219
+ if (initialSelection.serviceId &&
2220
+ initialSelection.serviceId === service._id) {
1987
2221
  return true;
1988
2222
  }
1989
2223
  if (initialSelection.serviceSlug &&
@@ -2027,6 +2261,70 @@ function readRecord(value) {
2027
2261
  function readString(value) {
2028
2262
  return typeof value === 'string' && value.trim() ? value : null;
2029
2263
  }
2264
+ async function prepareBookingPhoto(file) {
2265
+ if (!file.type.startsWith('image/')) {
2266
+ throw new Error('Please choose image files only.');
2267
+ }
2268
+ const dataUrl = await downscaleImageFile(file);
2269
+ const [, base64 = ''] = dataUrl.split(',');
2270
+ if (!base64 || base64.length > MAX_BOOKING_PHOTO_BASE64_LENGTH) {
2271
+ throw new Error('One of the photos is too large. Try a smaller image.');
2272
+ }
2273
+ return {
2274
+ id: `photo_${Date.now()}_${Math.random().toString(36).slice(2)}`,
2275
+ filename: jpegFileName(file.name),
2276
+ data: base64,
2277
+ size: Math.ceil((base64.length * 3) / 4),
2278
+ };
2279
+ }
2280
+ async function downscaleImageFile(file) {
2281
+ const image = await loadImageSource(file);
2282
+ const scale = Math.min(1, MAX_BOOKING_PHOTO_EDGE / Math.max(image.width, image.height));
2283
+ const width = Math.max(1, Math.round(image.width * scale));
2284
+ const height = Math.max(1, Math.round(image.height * scale));
2285
+ const canvas = document.createElement('canvas');
2286
+ canvas.width = width;
2287
+ canvas.height = height;
2288
+ const context = canvas.getContext('2d');
2289
+ if (!context) {
2290
+ throw new Error('Unable to prepare that photo.');
2291
+ }
2292
+ context.drawImage(image.source, 0, 0, width, height);
2293
+ image.close?.();
2294
+ return canvas.toDataURL('image/jpeg', BOOKING_PHOTO_JPEG_QUALITY);
2295
+ }
2296
+ async function loadImageSource(file) {
2297
+ if (typeof createImageBitmap === 'function') {
2298
+ const bitmap = await createImageBitmap(file);
2299
+ return {
2300
+ source: bitmap,
2301
+ width: bitmap.width,
2302
+ height: bitmap.height,
2303
+ close: () => bitmap.close(),
2304
+ };
2305
+ }
2306
+ const url = URL.createObjectURL(file);
2307
+ try {
2308
+ const image = await new Promise((resolve, reject) => {
2309
+ const element = new Image();
2310
+ element.onload = () => resolve(element);
2311
+ element.onerror = () => reject(new Error('Unable to read that photo.'));
2312
+ element.src = url;
2313
+ });
2314
+ return {
2315
+ source: image,
2316
+ width: image.naturalWidth || image.width,
2317
+ height: image.naturalHeight || image.height,
2318
+ };
2319
+ }
2320
+ finally {
2321
+ URL.revokeObjectURL(url);
2322
+ }
2323
+ }
2324
+ function jpegFileName(name) {
2325
+ const clean = name.trim() || 'booking-photo.jpg';
2326
+ return `${clean.replace(/\.[^.]+$/, '')}.jpg`;
2327
+ }
2030
2328
  // BookingWidgetSkeleton was a hand-built first-load ghost that
2031
2329
  // constantly drifted out of sync with the real layout. Boneyard now
2032
2330
  // owns the detailed bones; BookingWidgetPlaceholder is the stable
@@ -2263,11 +2561,35 @@ function zonedDateTimeToTimestamp(dateKey, time, timezone) {
2263
2561
  }
2264
2562
  return guess;
2265
2563
  }
2266
- function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, window, }) {
2564
+ function findFirstSelectableFlexibleDate({ calendarMonth, todayDateKey, service, businessHours, timezone, }) {
2565
+ const end = endOfMonth(calendarMonth);
2566
+ const cursor = startOfMonth(calendarMonth);
2567
+ while (cursor <= end) {
2568
+ const dateKey = formatDateKey(cursor);
2569
+ if (dateKey >= todayDateKey &&
2570
+ hasSelectableFlexibleWindowsForDate({
2571
+ dateKey,
2572
+ service,
2573
+ businessHours,
2574
+ timezone,
2575
+ })) {
2576
+ return dateKey;
2577
+ }
2578
+ cursor.setDate(cursor.getDate() + 1);
2579
+ }
2580
+ return null;
2581
+ }
2582
+ function hasSelectableFlexibleWindowsForDate({ dateKey, service, businessHours, timezone, }) {
2583
+ return getFlexibleWindowOptionsForDate({ dateKey, businessHours }).some((window) => isFlexibleWindowSelectable({
2584
+ dateKey,
2585
+ service,
2586
+ timezone,
2587
+ window,
2588
+ }));
2589
+ }
2590
+ function isFlexibleWindowSelectable({ dateKey, service, timezone, window, }) {
2267
2591
  if (!service)
2268
2592
  return false;
2269
- if (!isBusinessOpenOnDate(dateKey, businessHours))
2270
- return false;
2271
2593
  const now = Date.now();
2272
2594
  const windowEnd = zonedDateTimeToTimestamp(dateKey, window.endTime, timezone);
2273
2595
  const latestAllowedStart = now + (service.maxAdvanceBookingDays ?? 90) * 24 * 60 * 60 * 1000;
@@ -2277,13 +2599,61 @@ function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, w
2277
2599
  const windowStart = zonedDateTimeToTimestamp(dateKey, window.startTime, timezone);
2278
2600
  return windowStart <= latestAllowedStart;
2279
2601
  }
2280
- function isBusinessOpenOnDate(dateKey, businessHours) {
2281
- if (businessHours.length === 0)
2282
- return true;
2602
+ function getFlexibleWindowOptionsForDate({ dateKey, businessHours, }) {
2603
+ const range = getBusinessOpenRangeForDate(dateKey, businessHours);
2604
+ if (!range)
2605
+ return [];
2606
+ const allDay = makeFlexibleWindowOption('all-day', range.start, range.end);
2607
+ const morningEnd = Math.min(range.end, NOON_MINUTES);
2608
+ const afternoonStart = Math.max(range.start, NOON_MINUTES);
2609
+ const options = [allDay];
2610
+ if (morningEnd - range.start >= MIN_FLEXIBLE_WINDOW_MINUTES) {
2611
+ options.push(makeFlexibleWindowOption('morning', range.start, morningEnd));
2612
+ }
2613
+ if (range.end - afternoonStart >= MIN_FLEXIBLE_WINDOW_MINUTES) {
2614
+ options.push(makeFlexibleWindowOption('afternoon', afternoonStart, range.end));
2615
+ }
2616
+ return options;
2617
+ }
2618
+ function makeFlexibleWindowOption(id, startMinutes, endMinutes) {
2619
+ const template = FLEXIBLE_WINDOW_TEMPLATES.find((item) => item.id === id);
2620
+ return {
2621
+ id,
2622
+ startTime: minutesToClockTime(startMinutes),
2623
+ endTime: minutesToClockTime(endMinutes),
2624
+ labelKey: template?.labelKey ?? 'flexWindowAllDay',
2625
+ descriptionKey: template?.descriptionKey ?? 'flexWindowAllDayDesc',
2626
+ };
2627
+ }
2628
+ function getBusinessOpenRangeForDate(dateKey, businessHours) {
2629
+ if (businessHours.length === 0) {
2630
+ return {
2631
+ start: timeStringToMinutes(DEFAULT_FLEXIBLE_OPEN_TIME),
2632
+ end: timeStringToMinutes(DEFAULT_FLEXIBLE_CLOSE_TIME),
2633
+ };
2634
+ }
2283
2635
  const { year, month, day } = parseDateKeyParts(dateKey);
2284
2636
  const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
2285
2637
  const entry = businessHours.find((hours) => hours.day === dayOfWeek);
2286
- return entry?.isOpen === true;
2638
+ if (!entry?.isOpen)
2639
+ return null;
2640
+ const start = timeStringToMinutes(entry.openTime ?? DEFAULT_FLEXIBLE_OPEN_TIME);
2641
+ const end = timeStringToMinutes(entry.closeTime ?? DEFAULT_FLEXIBLE_CLOSE_TIME);
2642
+ return end > start ? { start, end } : null;
2643
+ }
2644
+ function minutesToClockTime(minutes) {
2645
+ const clamped = Math.max(0, Math.min(23 * 60 + 59, Math.round(minutes)));
2646
+ const hours = Math.floor(clamped / 60);
2647
+ const mins = clamped % 60;
2648
+ return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
2649
+ }
2650
+ function timeStringToMinutes(value) {
2651
+ const [hourString, minuteString] = value.split(':');
2652
+ const hour = Number(hourString);
2653
+ const minute = Number(minuteString);
2654
+ if (!Number.isFinite(hour) || !Number.isFinite(minute))
2655
+ return 0;
2656
+ return hour * 60 + minute;
2287
2657
  }
2288
2658
  function startOfMonth(date) {
2289
2659
  return new Date(date.getFullYear(), date.getMonth(), 1);
@@ -2393,6 +2763,9 @@ function WarningIcon() {
2393
2763
  function UserIcon() {
2394
2764
  return iconPath('M20 21a8 8 0 0 0-16 0m8-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8');
2395
2765
  }
2766
+ function LocationPinIcon() {
2767
+ return iconPath('M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Zm0-8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z');
2768
+ }
2396
2769
  function GlobeIcon() {
2397
2770
  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');
2398
2771
  }