@asksable/site-connector 0.6.3 → 0.6.5

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
@@ -396,12 +438,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
396
438
  setAvailabilityByDate(new Map());
397
439
  setIsAvailabilityLoading(false);
398
440
  if (selectedDate &&
399
- !isFlexibleDateSelectable({
441
+ !hasSelectableFlexibleWindowsForDate({
400
442
  dateKey: selectedDate,
401
443
  service: selectedService,
402
444
  businessHours: setup?.businessHours ?? [],
403
445
  timezone: bookingTimezone,
404
- window: selectedFlexibleWindow,
405
446
  })) {
406
447
  setSelectedDate(null);
407
448
  }
@@ -483,7 +524,6 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
483
524
  selectedService?.publicBookingMode,
484
525
  selectedServiceId,
485
526
  selectedStaffId,
486
- selectedFlexibleWindow,
487
527
  selectedService,
488
528
  setup?.businessHours,
489
529
  siteSlug,
@@ -756,7 +796,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
756
796
  (effectiveSchedulingPreference === 'flexible'
757
797
  ? Boolean(selectedDate && selectedFlexibleWindow)
758
798
  : Boolean(selectedDate && selectedSlot && holdId));
759
- const selectedTimeLabel = selectedDate && effectiveSchedulingPreference === 'flexible'
799
+ const selectedTimeLabel = selectedDate &&
800
+ effectiveSchedulingPreference === 'flexible' &&
801
+ selectedFlexibleWindow
760
802
  ? formatFlexibleWindowTimeRange(selectedFlexibleWindow, intlLocale)
761
803
  : selectedSlot
762
804
  ? `${formatTimeLabel(selectedSlot.time, intlLocale)} – ${formatTimeLabel(selectedSlot.endTime, intlLocale)}`
@@ -765,6 +807,7 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
765
807
  const canAdvanceStep3 = Boolean(selectedService &&
766
808
  customerName.trim() &&
767
809
  isCustomerEmailValid &&
810
+ (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress)) &&
768
811
  isIntakeComplete);
769
812
  // Slots block is rendered in two locations: inline under the calendar
770
813
  // (mobile flow keeps current step-3 behavior) and at the top of the
@@ -793,10 +836,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
793
836
  })] })) : null;
794
837
  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
838
  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) => {
839
+ 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) => {
840
+ const isActive = selectedFlexibleWindowId === option.id;
841
+ 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));
842
+ }), 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
843
  const slotKey = `${slot.time}-${slot.endTime}`;
801
844
  const isPending = pendingSlotKey === slotKey;
802
845
  const isActive = isPending ||
@@ -811,7 +854,8 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
811
854
  isIntakeComplete &&
812
855
  (selectedService.pricingMode !== 'calculated' || quote) &&
813
856
  customerName.trim() &&
814
- isCustomerEmailValid) && !isSubmitting;
857
+ isCustomerEmailValid &&
858
+ (isReschedule || (trimmedCustomerPhone && trimmedServiceAddress))) && !isSubmitting;
815
859
  const submitBlockers = selectedService
816
860
  ? getSubmitBlockers({
817
861
  selectedService,
@@ -823,6 +867,9 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
823
867
  quote,
824
868
  customerName,
825
869
  customerEmail,
870
+ customerPhone,
871
+ serviceAddress,
872
+ requireServiceAddress: !isReschedule,
826
873
  t,
827
874
  locale,
828
875
  })
@@ -830,9 +877,47 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
830
877
  function handleNotesInput(event) {
831
878
  setCustomerNotes(event.currentTarget.value);
832
879
  }
880
+ async function handlePhotoInput(event) {
881
+ const files = Array.from(event.currentTarget.files ?? []);
882
+ event.currentTarget.value = '';
883
+ if (files.length === 0)
884
+ return;
885
+ setPhotoError(null);
886
+ setIsPreparingPhotos(true);
887
+ try {
888
+ const remaining = Math.max(0, MAX_BOOKING_PHOTOS - bookingPhotos.length);
889
+ const accepted = files.slice(0, remaining);
890
+ const prepared = [];
891
+ for (const file of accepted) {
892
+ prepared.push(await prepareBookingPhoto(file));
893
+ }
894
+ setBookingPhotos((current) => [...current, ...prepared].slice(0, MAX_BOOKING_PHOTOS));
895
+ if (files.length > remaining) {
896
+ setPhotoError(t('photosLimit', { count: MAX_BOOKING_PHOTOS }));
897
+ }
898
+ }
899
+ catch (nextError) {
900
+ setPhotoError(nextError instanceof Error ? nextError.message : t('photosFailed'));
901
+ }
902
+ finally {
903
+ setIsPreparingPhotos(false);
904
+ }
905
+ }
833
906
  function renderNotesField(id, label, placeholder) {
834
907
  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
908
  }
909
+ function renderBookingContext() {
910
+ if (bookingContextRows.length === 0)
911
+ return null;
912
+ 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))) })] }));
913
+ }
914
+ function renderPhotoField(id) {
915
+ if (isReschedule)
916
+ return null;
917
+ 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', {
918
+ name: photo.filename,
919
+ }), children: t('photosRemove') })] }, photo.id))) })) : null] }));
920
+ }
836
921
  function renderSubmitHelp() {
837
922
  if (!selectedService || canSubmit || payment || isSubmitting)
838
923
  return null;
@@ -852,8 +937,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
852
937
  const nameId = `bw-name${idSuffix}`;
853
938
  const emailId = `bw-email${idSuffix}`;
854
939
  const phoneId = `bw-phone${idSuffix}`;
940
+ const addressId = `bw-service-address${idSuffix}`;
855
941
  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') })] })] }));
942
+ const phoneErrorId = `${phoneId}-error`;
943
+ const addressErrorId = `${addressId}-error`;
944
+ 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
945
  }
858
946
  function renderIntakeFields(idPrefix) {
859
947
  return (_jsx(IntakeFormFields, { form: selectedService?.intakeForm, responses: intakeResponses, onChange: (fieldId, value) => setIntakeResponses((prev) => ({ ...prev, [fieldId]: value })), idPrefix: idPrefix, mode: selectedServicePath }));
@@ -1045,16 +1133,21 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1045
1133
  : selectedDate && selectedSlot
1046
1134
  ? zonedDateTimeToTimestamp(selectedDate, selectedSlot.endTime, bookingTimezone)
1047
1135
  : newStartTime + selectedService.durationMinutes * 60 * 1000;
1136
+ const bookingBaseNotes = formatBookingNotesForSubmit({
1137
+ contextRows: bookingContextRows,
1138
+ additionalNotes: customerNotes,
1139
+ t,
1140
+ });
1048
1141
  const bookingNotes = isFlexibleRequest
1049
1142
  ? appendFlexibleWindowNote({
1050
- notes: customerNotes,
1143
+ notes: bookingBaseNotes,
1051
1144
  dateKey: selectedDate,
1052
1145
  window: selectedFlexibleWindow,
1053
1146
  locale: intlLocale,
1054
1147
  timezone: bookingTimezone,
1055
1148
  t,
1056
1149
  })
1057
- : customerNotes.trim() || undefined;
1150
+ : bookingBaseNotes || undefined;
1058
1151
  // Reschedule mode bypasses the public-create flow entirely.
1059
1152
  // Service / staff / customer are already attached to the
1060
1153
  // original appointment, so the host (admin path) or magic-link
@@ -1101,9 +1194,14 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1101
1194
  deliveryMethod: selectedService.deliveryMethods[0] ?? 'in-person',
1102
1195
  customerName: customerName.trim(),
1103
1196
  customerEmail: customerEmail.trim(),
1104
- customerPhone: customerPhone.trim() || undefined,
1197
+ customerPhone: trimmedCustomerPhone,
1105
1198
  customerNotes: bookingNotes,
1199
+ location: trimmedServiceAddress,
1106
1200
  intakeResponses,
1201
+ photos: bookingPhotos.map((photo) => ({
1202
+ filename: photo.filename,
1203
+ data: photo.data,
1204
+ })),
1107
1205
  quotedTotalCents: displayQuote?.totalCents,
1108
1206
  bookingHoldId: selectedServiceRequiresSlot && !isFlexibleRequest
1109
1207
  ? holdId
@@ -1145,7 +1243,10 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1145
1243
  setCustomerName('');
1146
1244
  setCustomerEmail('');
1147
1245
  setCustomerPhone('');
1246
+ setServiceAddress('');
1148
1247
  setCustomerNotes('');
1248
+ setBookingPhotos([]);
1249
+ setPhotoError(null);
1149
1250
  setIntakeResponses({});
1150
1251
  setQuote(null);
1151
1252
  setPayment(null);
@@ -1290,12 +1391,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1290
1391
  const isExactAvailable = slots.length > 0;
1291
1392
  const isFlexibleAvailable = effectiveSchedulingPreference === 'flexible' &&
1292
1393
  selectedService != null &&
1293
- isFlexibleDateSelectable({
1394
+ hasSelectableFlexibleWindowsForDate({
1294
1395
  dateKey,
1295
1396
  service: selectedService,
1296
1397
  businessHours: setup?.businessHours ?? [],
1297
1398
  timezone: bookingTimezone,
1298
- window: selectedFlexibleWindow,
1299
1399
  });
1300
1400
  const isAvailable = effectiveSchedulingPreference === 'flexible'
1301
1401
  ? isFlexibleAvailable
@@ -1352,18 +1452,18 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1352
1452
  }, 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
1453
  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
1454
  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') })] })] })] }), (() => {
1455
+ 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
1456
  const intakeRows = buildIntakeReviewRows(selectedService.intakeForm, intakeResponses, selectedServicePath, locale, t);
1357
1457
  if (intakeRows.length === 0)
1358
1458
  return null;
1359
1459
  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
1460
+ })(), 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
1461
  ? 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
1462
+ : 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
1463
  ? t('summaryCalculating')
1364
1464
  : 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
1465
  ? 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
1466
+ : 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
1467
  ? t('summaryTitleReschedule')
1368
1468
  : 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
1469
  ...summaryValStyle,
@@ -1398,11 +1498,11 @@ export function BookingWidgetPanel({ title, description, mobileHeader, mode = 'c
1398
1498
  ? t('summaryNewTime')
1399
1499
  : 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
1500
  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
1501
+ 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
1502
  ? 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
1503
+ : 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
1504
  ? t('notesPlaceholderReschedule')
1405
- : t('notesPlaceholder')), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
1505
+ : t('notesPlaceholder')), renderPhotoField('bw-photos-d'), error ? _jsx("div", { className: "bw-error", children: error }) : null, renderSubmitHelp(), !payment &&
1406
1506
  (forcedState === 'payment-full' ||
1407
1507
  forcedState === 'payment-deposit') ? (_jsx(PaymentPanel, { service: selectedService, mode: forcedState === 'payment-full' ? 'full' : 'deposit' })) : null, payment ? (_jsx(StripePaymentBlock, { payment: payment, appointmentId: paymentAppointmentId, onSuccess: () => {
1408
1508
  setSuccess(t('successPaymentReceived'));
@@ -1512,7 +1612,7 @@ function areRequiredIntakeFieldsComplete(form, responses, mode) {
1512
1612
  return isRequiredIntakeFieldComplete(field, responses[field.id], minItems);
1513
1613
  }));
1514
1614
  }
1515
- function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, t, locale = DEFAULT_LOCALE, }) {
1615
+ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasScheduleSelection, isIntakeComplete, intakeResponses, selectedServicePath, quote, customerName, customerEmail, customerPhone, serviceAddress, requireServiceAddress, t, locale = DEFAULT_LOCALE, }) {
1516
1616
  const blockers = [];
1517
1617
  if (selectedServiceRequiresSlot && !hasScheduleSelection) {
1518
1618
  blockers.push(t('blockerDateTime'));
@@ -1525,6 +1625,11 @@ function getSubmitBlockers({ selectedService, selectedServiceRequiresSlot, hasSc
1525
1625
  else if (!isValidEmailAddress(customerEmail)) {
1526
1626
  blockers.push(t('blockerContactEmailInvalid'));
1527
1627
  }
1628
+ if (!customerPhone.trim())
1629
+ blockers.push(t('blockerContactPhone'));
1630
+ if (requireServiceAddress && !serviceAddress.trim()) {
1631
+ blockers.push(t('blockerServiceAddress'));
1632
+ }
1528
1633
  if (!isIntakeComplete && selectedService.intakeForm) {
1529
1634
  blockers.push(...getMissingRequiredIntakeLabels(selectedService.intakeForm, intakeResponses, selectedServicePath, t, locale));
1530
1635
  }
@@ -1671,6 +1776,123 @@ function buildIntakeReviewRows(form, responses, servicePath, locale, t) {
1671
1776
  }
1672
1777
  return rows;
1673
1778
  }
1779
+ function buildBookingContextRows({ intakeResponses, quote, locale, }) {
1780
+ const rows = [];
1781
+ const usedKeys = new Set();
1782
+ const addKnown = (keys, label, formatter = formatContextValue) => {
1783
+ for (const key of keys) {
1784
+ const value = intakeResponses[key];
1785
+ const formatted = formatter(value);
1786
+ if (formatted) {
1787
+ rows.push({ key, label, value: formatted });
1788
+ usedKeys.add(key);
1789
+ return;
1790
+ }
1791
+ }
1792
+ };
1793
+ addKnown(['vehicle_size', 'vehicleSize', 'vehicle', 'vehicle_type', 'vehicleType'], 'Vehicle', formatVehicleContextValue);
1794
+ addKnown(['pet_hair', 'petHair', 'pet_hair_level'], 'Pet hair');
1795
+ addKnown([
1796
+ 'stains',
1797
+ 'stain_removal',
1798
+ 'stainRemoval',
1799
+ 'shampooing',
1800
+ 'shampoo_needed',
1801
+ 'shampooNeeded',
1802
+ 'stains_or_shampooing',
1803
+ ], 'Stains or shampooing');
1804
+ const estimateCents = readCentsValue(intakeResponses.estimate_cents) ??
1805
+ readCentsValue(intakeResponses.estimateCents) ??
1806
+ quote?.totalCents ??
1807
+ null;
1808
+ if (estimateCents !== null) {
1809
+ rows.push({
1810
+ key: 'estimate_cents',
1811
+ label: 'Website estimate',
1812
+ value: currencyFormatter(quote?.currency ?? 'usd', locale).format(estimateCents / 100),
1813
+ });
1814
+ usedKeys.add('estimate_cents');
1815
+ usedKeys.add('estimateCents');
1816
+ }
1817
+ for (const [key, value] of Object.entries(intakeResponses)) {
1818
+ if (usedKeys.has(key))
1819
+ continue;
1820
+ if (key === 'estimate_label' || key === 'estimateLabel')
1821
+ continue;
1822
+ const formatted = formatContextValue(value);
1823
+ if (!formatted)
1824
+ continue;
1825
+ rows.push({
1826
+ key,
1827
+ label: formatContextKey(key),
1828
+ value: formatted,
1829
+ });
1830
+ if (rows.length >= 8)
1831
+ break;
1832
+ }
1833
+ return rows;
1834
+ }
1835
+ function formatVehicleContextValue(value) {
1836
+ const raw = formatContextValue(value);
1837
+ if (!raw)
1838
+ return null;
1839
+ const normalized = raw.toLowerCase().replace(/[\s_-]+/g, '-');
1840
+ const labels = {
1841
+ car: 'Car or small SUV',
1842
+ small: 'Car or small SUV',
1843
+ sedan: 'Car or small SUV',
1844
+ 'small-suv': 'Car or small SUV',
1845
+ large: 'Large or 3-row',
1846
+ '3-row': 'Large or 3-row',
1847
+ 'three-row': 'Large or 3-row',
1848
+ suv: 'Large or 3-row',
1849
+ van: 'Large or 3-row',
1850
+ minivan: 'Large or 3-row',
1851
+ truck: 'Large or 3-row',
1852
+ };
1853
+ return labels[normalized] ?? raw;
1854
+ }
1855
+ function formatContextValue(value) {
1856
+ if (typeof value === 'string') {
1857
+ const trimmed = value.trim();
1858
+ if (!trimmed)
1859
+ return null;
1860
+ return titleizeContextValue(trimmed);
1861
+ }
1862
+ if (typeof value === 'number' && Number.isFinite(value))
1863
+ return String(value);
1864
+ if (typeof value === 'boolean')
1865
+ return value ? 'Yes' : 'No';
1866
+ if (Array.isArray(value)) {
1867
+ const parts = value.map(formatContextValue).filter(Boolean);
1868
+ return parts.length ? parts.join(', ') : null;
1869
+ }
1870
+ return null;
1871
+ }
1872
+ function titleizeContextValue(value) {
1873
+ return value
1874
+ .replace(/[_-]+/g, ' ')
1875
+ .replace(/\s+/g, ' ')
1876
+ .trim()
1877
+ .replace(/\b\w/g, (char) => char.toUpperCase());
1878
+ }
1879
+ function formatContextKey(key) {
1880
+ return titleizeContextValue(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
1881
+ }
1882
+ function formatBookingNotesForSubmit({ contextRows, additionalNotes, t, }) {
1883
+ const sections = [];
1884
+ if (contextRows.length > 0) {
1885
+ sections.push([
1886
+ `${t('bookingContextTitle')}:`,
1887
+ ...contextRows.map((row) => `${row.label}: ${row.value}`),
1888
+ ].join('\n'));
1889
+ }
1890
+ const trimmed = additionalNotes.trim();
1891
+ if (trimmed) {
1892
+ sections.push(`${t('notesLabel')}:\n${trimmed}`);
1893
+ }
1894
+ return sections.join('\n\n');
1895
+ }
1674
1896
  function readFiniteNumber(value, fallback) {
1675
1897
  if (typeof value === 'number' && Number.isFinite(value))
1676
1898
  return value;
@@ -1983,7 +2205,8 @@ function readCentsValue(value) {
1983
2205
  function initialSelectionMatchesService(initialSelection, service) {
1984
2206
  if (!initialSelection)
1985
2207
  return false;
1986
- if (initialSelection.serviceId && initialSelection.serviceId === service._id) {
2208
+ if (initialSelection.serviceId &&
2209
+ initialSelection.serviceId === service._id) {
1987
2210
  return true;
1988
2211
  }
1989
2212
  if (initialSelection.serviceSlug &&
@@ -2027,6 +2250,70 @@ function readRecord(value) {
2027
2250
  function readString(value) {
2028
2251
  return typeof value === 'string' && value.trim() ? value : null;
2029
2252
  }
2253
+ async function prepareBookingPhoto(file) {
2254
+ if (!file.type.startsWith('image/')) {
2255
+ throw new Error('Please choose image files only.');
2256
+ }
2257
+ const dataUrl = await downscaleImageFile(file);
2258
+ const [, base64 = ''] = dataUrl.split(',');
2259
+ if (!base64 || base64.length > MAX_BOOKING_PHOTO_BASE64_LENGTH) {
2260
+ throw new Error('One of the photos is too large. Try a smaller image.');
2261
+ }
2262
+ return {
2263
+ id: `photo_${Date.now()}_${Math.random().toString(36).slice(2)}`,
2264
+ filename: jpegFileName(file.name),
2265
+ data: base64,
2266
+ size: Math.ceil((base64.length * 3) / 4),
2267
+ };
2268
+ }
2269
+ async function downscaleImageFile(file) {
2270
+ const image = await loadImageSource(file);
2271
+ const scale = Math.min(1, MAX_BOOKING_PHOTO_EDGE / Math.max(image.width, image.height));
2272
+ const width = Math.max(1, Math.round(image.width * scale));
2273
+ const height = Math.max(1, Math.round(image.height * scale));
2274
+ const canvas = document.createElement('canvas');
2275
+ canvas.width = width;
2276
+ canvas.height = height;
2277
+ const context = canvas.getContext('2d');
2278
+ if (!context) {
2279
+ throw new Error('Unable to prepare that photo.');
2280
+ }
2281
+ context.drawImage(image.source, 0, 0, width, height);
2282
+ image.close?.();
2283
+ return canvas.toDataURL('image/jpeg', BOOKING_PHOTO_JPEG_QUALITY);
2284
+ }
2285
+ async function loadImageSource(file) {
2286
+ if (typeof createImageBitmap === 'function') {
2287
+ const bitmap = await createImageBitmap(file);
2288
+ return {
2289
+ source: bitmap,
2290
+ width: bitmap.width,
2291
+ height: bitmap.height,
2292
+ close: () => bitmap.close(),
2293
+ };
2294
+ }
2295
+ const url = URL.createObjectURL(file);
2296
+ try {
2297
+ const image = await new Promise((resolve, reject) => {
2298
+ const element = new Image();
2299
+ element.onload = () => resolve(element);
2300
+ element.onerror = () => reject(new Error('Unable to read that photo.'));
2301
+ element.src = url;
2302
+ });
2303
+ return {
2304
+ source: image,
2305
+ width: image.naturalWidth || image.width,
2306
+ height: image.naturalHeight || image.height,
2307
+ };
2308
+ }
2309
+ finally {
2310
+ URL.revokeObjectURL(url);
2311
+ }
2312
+ }
2313
+ function jpegFileName(name) {
2314
+ const clean = name.trim() || 'booking-photo.jpg';
2315
+ return `${clean.replace(/\.[^.]+$/, '')}.jpg`;
2316
+ }
2030
2317
  // BookingWidgetSkeleton was a hand-built first-load ghost that
2031
2318
  // constantly drifted out of sync with the real layout. Boneyard now
2032
2319
  // owns the detailed bones; BookingWidgetPlaceholder is the stable
@@ -2263,11 +2550,17 @@ function zonedDateTimeToTimestamp(dateKey, time, timezone) {
2263
2550
  }
2264
2551
  return guess;
2265
2552
  }
2266
- function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, window, }) {
2553
+ function hasSelectableFlexibleWindowsForDate({ dateKey, service, businessHours, timezone, }) {
2554
+ return getFlexibleWindowOptionsForDate({ dateKey, businessHours }).some((window) => isFlexibleWindowSelectable({
2555
+ dateKey,
2556
+ service,
2557
+ timezone,
2558
+ window,
2559
+ }));
2560
+ }
2561
+ function isFlexibleWindowSelectable({ dateKey, service, timezone, window, }) {
2267
2562
  if (!service)
2268
2563
  return false;
2269
- if (!isBusinessOpenOnDate(dateKey, businessHours))
2270
- return false;
2271
2564
  const now = Date.now();
2272
2565
  const windowEnd = zonedDateTimeToTimestamp(dateKey, window.endTime, timezone);
2273
2566
  const latestAllowedStart = now + (service.maxAdvanceBookingDays ?? 90) * 24 * 60 * 60 * 1000;
@@ -2277,13 +2570,61 @@ function isFlexibleDateSelectable({ dateKey, service, businessHours, timezone, w
2277
2570
  const windowStart = zonedDateTimeToTimestamp(dateKey, window.startTime, timezone);
2278
2571
  return windowStart <= latestAllowedStart;
2279
2572
  }
2280
- function isBusinessOpenOnDate(dateKey, businessHours) {
2281
- if (businessHours.length === 0)
2282
- return true;
2573
+ function getFlexibleWindowOptionsForDate({ dateKey, businessHours, }) {
2574
+ const range = getBusinessOpenRangeForDate(dateKey, businessHours);
2575
+ if (!range)
2576
+ return [];
2577
+ const allDay = makeFlexibleWindowOption('all-day', range.start, range.end);
2578
+ const morningEnd = Math.min(range.end, NOON_MINUTES);
2579
+ const afternoonStart = Math.max(range.start, NOON_MINUTES);
2580
+ const options = [allDay];
2581
+ if (morningEnd - range.start >= MIN_FLEXIBLE_WINDOW_MINUTES) {
2582
+ options.push(makeFlexibleWindowOption('morning', range.start, morningEnd));
2583
+ }
2584
+ if (range.end - afternoonStart >= MIN_FLEXIBLE_WINDOW_MINUTES) {
2585
+ options.push(makeFlexibleWindowOption('afternoon', afternoonStart, range.end));
2586
+ }
2587
+ return options;
2588
+ }
2589
+ function makeFlexibleWindowOption(id, startMinutes, endMinutes) {
2590
+ const template = FLEXIBLE_WINDOW_TEMPLATES.find((item) => item.id === id);
2591
+ return {
2592
+ id,
2593
+ startTime: minutesToClockTime(startMinutes),
2594
+ endTime: minutesToClockTime(endMinutes),
2595
+ labelKey: template?.labelKey ?? 'flexWindowAllDay',
2596
+ descriptionKey: template?.descriptionKey ?? 'flexWindowAllDayDesc',
2597
+ };
2598
+ }
2599
+ function getBusinessOpenRangeForDate(dateKey, businessHours) {
2600
+ if (businessHours.length === 0) {
2601
+ return {
2602
+ start: timeStringToMinutes(DEFAULT_FLEXIBLE_OPEN_TIME),
2603
+ end: timeStringToMinutes(DEFAULT_FLEXIBLE_CLOSE_TIME),
2604
+ };
2605
+ }
2283
2606
  const { year, month, day } = parseDateKeyParts(dateKey);
2284
2607
  const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
2285
2608
  const entry = businessHours.find((hours) => hours.day === dayOfWeek);
2286
- return entry?.isOpen === true;
2609
+ if (!entry?.isOpen)
2610
+ return null;
2611
+ const start = timeStringToMinutes(entry.openTime ?? DEFAULT_FLEXIBLE_OPEN_TIME);
2612
+ const end = timeStringToMinutes(entry.closeTime ?? DEFAULT_FLEXIBLE_CLOSE_TIME);
2613
+ return end > start ? { start, end } : null;
2614
+ }
2615
+ function minutesToClockTime(minutes) {
2616
+ const clamped = Math.max(0, Math.min(23 * 60 + 59, Math.round(minutes)));
2617
+ const hours = Math.floor(clamped / 60);
2618
+ const mins = clamped % 60;
2619
+ return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
2620
+ }
2621
+ function timeStringToMinutes(value) {
2622
+ const [hourString, minuteString] = value.split(':');
2623
+ const hour = Number(hourString);
2624
+ const minute = Number(minuteString);
2625
+ if (!Number.isFinite(hour) || !Number.isFinite(minute))
2626
+ return 0;
2627
+ return hour * 60 + minute;
2287
2628
  }
2288
2629
  function startOfMonth(date) {
2289
2630
  return new Date(date.getFullYear(), date.getMonth(), 1);
@@ -2393,6 +2734,9 @@ function WarningIcon() {
2393
2734
  function UserIcon() {
2394
2735
  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
2736
  }
2737
+ function LocationPinIcon() {
2738
+ 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');
2739
+ }
2396
2740
  function GlobeIcon() {
2397
2741
  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
2742
  }