@ticketboothapp/booking 1.2.101 → 1.2.102

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/BookingDialog.module.css +9 -0
  3. package/src/components/booking/BookingProductGrid.module.css +11 -0
  4. package/src/components/booking/BookingProductGrid.tsx +54 -28
  5. package/src/components/booking/CancellationPolicySelector.tsx +4 -1
  6. package/src/components/booking/CheckoutForm.module.css +108 -3
  7. package/src/components/booking/CheckoutForm.tsx +13 -1
  8. package/src/components/booking/CheckoutOptionalPhoneFields.tsx +58 -0
  9. package/src/components/booking/DapTourDescription.tsx +9 -7
  10. package/src/components/booking/DependentAddOnBookingDialog.tsx +42 -7
  11. package/src/components/booking/NewBookingFlow.tsx +137 -55
  12. package/src/components/booking/PrivateShuttleBookingFlow.module.css +7 -0
  13. package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -0
  14. package/src/components/booking/booking-flow-types.ts +2 -0
  15. package/src/components/booking/booking-flow-ui.ts +2 -0
  16. package/src/components/booking/booking-flow.css +72 -4
  17. package/src/data/dap-descriptions/session-couples-families-friends.en.json +0 -3
  18. package/src/data/dap-descriptions/session-elopements.en.json +12 -12
  19. package/src/data/dap-descriptions/session-proposals.en.json +6 -9
  20. package/src/data/products-config.json +20 -0
  21. package/src/lib/booking/checkout-contact.ts +8 -0
  22. package/src/lib/booking/i18n/messages/en.json +6 -0
  23. package/src/lib/booking/i18n/messages/fr.json +6 -0
  24. package/src/lib/booking/phone.ts +18 -0
  25. package/src/lib/booking-api.ts +131 -2
  26. package/src/lib/booking-types.ts +5 -0
  27. package/src/lib/dap-descriptions.ts +6 -0
  28. package/src/lib/dependent-add-on-api.ts +6 -0
  29. package/src/lib/photo-dap-config.ts +92 -15
  30. package/src/providers/dependent-add-on-dialog-provider.tsx +6 -0
  31. package/src/runtime/types.ts +2 -0
  32. package/src/strings/en.json +6 -6
@@ -72,6 +72,7 @@ import { useBookingHost } from '../../runtime';
72
72
  import type { BookingFlowUiOptions } from './booking-flow-ui';
73
73
  import type { NewBookingFlowProps } from './booking-flow-types';
74
74
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
75
+ import { optionalCheckoutContactFields } from '../../lib/booking/checkout-contact';
75
76
 
76
77
  function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
77
78
  const labels: Record<string, string> = {
@@ -176,6 +177,74 @@ function availabilityWithOptionId(availability: Availability, fallbackOptionId?:
176
177
  return productOptionId ? { ...availability, productOptionId } : availability;
177
178
  }
178
179
 
180
+ /** Prefer full availability rows over calendar summary rows when merging fetches. */
181
+ function mergeAvailabilityRecord(
182
+ existing: Availability | undefined,
183
+ incoming: Availability,
184
+ ): Availability {
185
+ if (!existing) return incoming;
186
+ const existingIsSummary = existing.isSummary === true;
187
+ const incomingIsSummary = incoming.isSummary === true;
188
+ if (existingIsSummary && !incomingIsSummary) return incoming;
189
+ if (!existingIsSummary && incomingIsSummary) return existing;
190
+ return incoming;
191
+ }
192
+
193
+ function mergeAvailabilitiesIntoMap(
194
+ map: Map<string, Availability>,
195
+ availabilities: Availability[],
196
+ ): void {
197
+ for (const availability of availabilities) {
198
+ const key = `${availability.dateTime}-${availability.productOptionId}`;
199
+ map.set(key, mergeAvailabilityRecord(map.get(key), availability));
200
+ }
201
+ }
202
+
203
+ function pickDefaultAvailabilityForTimes(
204
+ times: Availability[],
205
+ activeOptions: Product['options'],
206
+ ): Availability | null {
207
+ if (times.length === 0) return null;
208
+ const mostPopularOption = activeOptions.find((opt) => opt.mostPopular);
209
+ const candidate = mostPopularOption
210
+ ? times.find(
211
+ (avail) => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0,
212
+ )
213
+ : null;
214
+ const fallback = times.find((avail) => avail.vacancies > 0);
215
+ return candidate ?? fallback ?? null;
216
+ }
217
+
218
+ function availabilityMatchesSelectedDate(
219
+ availability: Availability,
220
+ selectedDate: string,
221
+ companyTimezone: string,
222
+ ): boolean {
223
+ try {
224
+ const dateInCompanyTz = formatInTimeZone(
225
+ parseAvailabilityDateTime(availability.dateTime),
226
+ companyTimezone,
227
+ 'yyyy-MM-dd',
228
+ );
229
+ return dateInCompanyTz === selectedDate;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ function shouldSyncSelectedAvailability(
236
+ current: Availability,
237
+ updated: Availability,
238
+ ): boolean {
239
+ return (
240
+ current !== updated &&
241
+ (current.isSummary !== updated.isSummary ||
242
+ current.vacancies !== updated.vacancies ||
243
+ current.availabilityId !== updated.availabilityId ||
244
+ (current.returnOptions?.length ?? 0) !== (updated.returnOptions?.length ?? 0))
245
+ );
246
+ }
247
+
179
248
  function mergePrecomputedPricesFromAvailabilityResult(
180
249
  next: Record<string, PrecomputedPricesByCategory>,
181
250
  result: GetAvailabilitiesResponse,
@@ -343,6 +412,7 @@ export function NewBookingFlow({
343
412
  hideItineraryBox = false,
344
413
  flowUi,
345
414
  afterItinerary,
415
+ beforeCancellationPolicy,
346
416
  augmentItineraryItems,
347
417
  augmentPriceSummary,
348
418
  dependentAddOnSelection,
@@ -376,6 +446,7 @@ export function NewBookingFlow({
376
446
  const [email, setEmail] = useState('');
377
447
  const [firstName, setFirstName] = useState('');
378
448
  const [lastName, setLastName] = useState('');
449
+ const [phoneNumber, setPhoneNumber] = useState('');
379
450
  const [promoCodeInput, setPromoCodeInput] = useState('');
380
451
  const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
381
452
  const [promoCodeError, setPromoCodeError] = useState('');
@@ -474,7 +545,6 @@ export function NewBookingFlow({
474
545
  const hasHydratedAddOnsFromReceiptRef = useRef(false);
475
546
  const hasAutoSelectedPartnerDateRef = useRef(false);
476
547
  const hasAutoSelectedPartnerPickupRef = useRef(false);
477
- const handleDateSelectRef = useRef<(date: string) => void>(() => {});
478
548
  useEffect(() => {
479
549
  setPartnerAttributionConfirmed(false);
480
550
  }, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
@@ -632,9 +702,7 @@ export function NewBookingFlow({
632
702
  const existingMap = new Map(
633
703
  prev.map((avail) => [`${avail.dateTime}-${avail.productOptionId}`, avail])
634
704
  );
635
- allFetchedAvailabilities.forEach((avail) => {
636
- existingMap.set(`${avail.dateTime}-${avail.productOptionId}`, avail);
637
- });
705
+ mergeAvailabilitiesIntoMap(existingMap, allFetchedAvailabilities);
638
706
  mergedOut = Array.from(existingMap.values());
639
707
  return mergedOut;
640
708
  });
@@ -668,9 +736,7 @@ export function NewBookingFlow({
668
736
  const mergedAvailabilitiesMap = new Map(
669
737
  existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
670
738
  );
671
- allFetchedAvailabilities.forEach((a) => {
672
- mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
673
- });
739
+ mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
674
740
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
675
741
  results.forEach((r) => {
676
742
  mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
@@ -753,9 +819,7 @@ export function NewBookingFlow({
753
819
  let mergedOut: Availability[] = [];
754
820
  setAvailabilities((prev) => {
755
821
  const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
756
- detailedAvailabilities.forEach((availability) => {
757
- merged.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
758
- });
822
+ mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
759
823
  mergedOut = Array.from(merged.values());
760
824
  return mergedOut;
761
825
  });
@@ -776,9 +840,7 @@ export function NewBookingFlow({
776
840
  availability,
777
841
  ])
778
842
  );
779
- detailedAvailabilities.forEach((availability) => {
780
- mergedAvailabilities.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
781
- });
843
+ mergeAvailabilitiesIntoMap(mergedAvailabilities, detailedAvailabilities);
782
844
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
783
845
  results.forEach((r) => mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r));
784
846
  availabilitiesCache.merge(cacheKey, {
@@ -1013,12 +1075,7 @@ export function NewBookingFlow({
1013
1075
  );
1014
1076
 
1015
1077
  // Merge new availabilities - update existing ones or add new ones
1016
- allFetchedAvailabilities.forEach(avail => {
1017
- const key = `${avail.dateTime}-${avail.productOptionId}`;
1018
- // Always update to get latest data (vacancies, prices, etc.)
1019
- existingMap.set(key, avail);
1020
- });
1021
-
1078
+ mergeAvailabilitiesIntoMap(existingMap, allFetchedAvailabilities);
1022
1079
  return Array.from(existingMap.values());
1023
1080
  });
1024
1081
 
@@ -1045,9 +1102,7 @@ export function NewBookingFlow({
1045
1102
  const mergedAvailabilitiesMap = new Map(
1046
1103
  existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
1047
1104
  );
1048
- allFetchedAvailabilities.forEach((a) => {
1049
- mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
1050
- });
1105
+ mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
1051
1106
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
1052
1107
  results.forEach((r) => {
1053
1108
  mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
@@ -1216,9 +1271,7 @@ export function NewBookingFlow({
1216
1271
 
1217
1272
  setAvailabilities((prev) => {
1218
1273
  const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
1219
- detailedAvailabilities.forEach((availability) => {
1220
- merged.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
1221
- });
1274
+ mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
1222
1275
  return Array.from(merged.values());
1223
1276
  });
1224
1277
 
@@ -1238,9 +1291,7 @@ export function NewBookingFlow({
1238
1291
  availability,
1239
1292
  ])
1240
1293
  );
1241
- detailedAvailabilities.forEach((availability) => {
1242
- mergedAvailabilities.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
1243
- });
1294
+ mergeAvailabilitiesIntoMap(mergedAvailabilities, detailedAvailabilities);
1244
1295
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
1245
1296
  results.forEach((result) => {
1246
1297
  mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, result);
@@ -1423,6 +1474,11 @@ export function NewBookingFlow({
1423
1474
  [timesForSelectedDate],
1424
1475
  );
1425
1476
 
1477
+ const selectedAvailabilityMatchesDate = useMemo(() => {
1478
+ if (!selectedAvailability || !selectedDate) return false;
1479
+ return availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone);
1480
+ }, [selectedAvailability, selectedDate, companyTimezone]);
1481
+
1426
1482
  useEffect(() => {
1427
1483
  if (hasAppliedInitialValuesRef.current || !initialValues) return;
1428
1484
  const trimmedEmail = initialValues.customer?.email?.trim();
@@ -2248,21 +2304,27 @@ export function NewBookingFlow({
2248
2304
  totalPrice,
2249
2305
  ]);
2250
2306
 
2251
- // Auto-select product option when date is selected: most popular if set, otherwise first available.
2307
+ // Auto-select pickup when date is selected (or when slots load after date pick).
2252
2308
  useEffect(() => {
2253
- if (selectedDate && timesForSelectedDate.length > 0 && !selectedAvailability) {
2254
- const mostPopularOption = activeOptions.find(opt => opt.mostPopular);
2255
- const candidate = mostPopularOption
2256
- ? timesForSelectedDate.find(avail => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0)
2257
- : null;
2258
- const fallback = timesForSelectedDate.find(avail => avail.vacancies > 0);
2259
- const toSelect = candidate ?? fallback;
2260
- if (toSelect) {
2261
- setSelectedAvailability(toSelect);
2262
- setError('');
2263
- }
2309
+ if (!selectedDate || timesForSelectedDate.length === 0) return;
2310
+ if (
2311
+ selectedAvailability &&
2312
+ availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)
2313
+ ) {
2314
+ return;
2315
+ }
2316
+ const toSelect = pickDefaultAvailabilityForTimes(timesForSelectedDate, activeOptions);
2317
+ if (toSelect) {
2318
+ setSelectedAvailability(toSelect);
2319
+ setError('');
2264
2320
  }
2265
- }, [selectedDate, timesForSelectedDateSelectionKey, activeOptionIdsKey, selectedAvailability]);
2321
+ }, [
2322
+ selectedDate,
2323
+ timesForSelectedDateSelectionKey,
2324
+ activeOptions,
2325
+ selectedAvailability,
2326
+ companyTimezone,
2327
+ ]);
2266
2328
 
2267
2329
  // Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
2268
2330
  // changeByCurrency, feesByCurrency, precomputedPrices, etc.) in one response; we just
@@ -2276,7 +2338,7 @@ export function NewBookingFlow({
2276
2338
  avail.dateTime === selectedAvailability.dateTime &&
2277
2339
  avail.productOptionId === selectedAvailability.productOptionId
2278
2340
  );
2279
- if (updatedAvailability) {
2341
+ if (updatedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedAvailability)) {
2280
2342
  setSelectedAvailability(updatedAvailability);
2281
2343
 
2282
2344
  // Also update selectedReturnOption if it exists
@@ -2362,14 +2424,6 @@ export function NewBookingFlow({
2362
2424
  }
2363
2425
  }, [pricingConfig?.cancellationPolicies, cancellationPolicyId, currency]);
2364
2426
 
2365
- const handleDateSelect = (date: string) => {
2366
- if (date === selectedDate) return;
2367
- setSelectedAvailability(null);
2368
- setSelectedReturnOption(null);
2369
- };
2370
-
2371
- handleDateSelectRef.current = handleDateSelect;
2372
-
2373
2427
  useEffect(() => {
2374
2428
  if (flowUi?.autoSelectFirstAvailableDate !== true) return;
2375
2429
  if (isPartialLaunch) return;
@@ -2388,7 +2442,10 @@ export function NewBookingFlow({
2388
2442
 
2389
2443
  hasAutoSelectedPartnerDateRef.current = true;
2390
2444
  setSelectedDate(first);
2391
- handleDateSelectRef.current(first);
2445
+ setSelectedReturnOption(null);
2446
+ setSelectedAvailability(
2447
+ pickDefaultAvailabilityForTimes(availabilitiesByDate[first] ?? [], activeOptions),
2448
+ );
2392
2449
  if (!suppressCalendarDateScroll) {
2393
2450
  setTimeout(() => {
2394
2451
  const container = contentRef?.current;
@@ -2874,6 +2931,7 @@ export function NewBookingFlow({
2874
2931
  customerEmail: email || undefined,
2875
2932
  customerFirstName: firstName.trim() || undefined,
2876
2933
  customerLastName: lastName.trim() || undefined,
2934
+ ...optionalCheckoutContactFields(phoneNumber),
2877
2935
  currency: currency,
2878
2936
  travelerHotel: selectedPickupLocation?.name || undefined,
2879
2937
  pickupLocationId: pickupLocationId || undefined,
@@ -2914,6 +2972,7 @@ export function NewBookingFlow({
2914
2972
  customerEmail: email,
2915
2973
  customerFirstName: firstName.trim() || undefined,
2916
2974
  customerLastName: lastName.trim() || undefined,
2975
+ ...optionalCheckoutContactFields(phoneNumber),
2917
2976
  currency: currency,
2918
2977
  reservationReference: reservation?.reservationReference,
2919
2978
  travelerHotel: selectedPickupLocation?.name || undefined,
@@ -2945,6 +3004,7 @@ export function NewBookingFlow({
2945
3004
  customerEmail: email || undefined,
2946
3005
  customerFirstName: firstName.trim() || undefined,
2947
3006
  customerLastName: lastName.trim() || undefined,
3007
+ ...optionalCheckoutContactFields(phoneNumber),
2948
3008
  currency: currency,
2949
3009
  travelerHotel: selectedPickupLocation?.name || undefined,
2950
3010
  pickupLocationId: pickupLocationId || undefined,
@@ -3130,6 +3190,7 @@ export function NewBookingFlow({
3130
3190
  customerEmail: email || undefined,
3131
3191
  customerFirstName: firstName.trim() || undefined,
3132
3192
  customerLastName: lastName.trim() || undefined,
3193
+ ...optionalCheckoutContactFields(phoneNumber),
3133
3194
  currency: currency,
3134
3195
  travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
3135
3196
  pickupLocationId: pickupLocationId || undefined,
@@ -3317,8 +3378,18 @@ export function NewBookingFlow({
3317
3378
  selectableSoldOutDate={null}
3318
3379
  isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
3319
3380
  onDateSelect={(date) => {
3381
+ const dateChanged = date !== selectedDate;
3320
3382
  setSelectedDate(date);
3321
- handleDateSelect(date);
3383
+ if (dateChanged) {
3384
+ setSelectedReturnOption(null);
3385
+ setSelectedAvailability(
3386
+ pickDefaultAvailabilityForTimes(
3387
+ availabilitiesByDate[date] ?? [],
3388
+ activeOptions,
3389
+ ),
3390
+ );
3391
+ setError('');
3392
+ }
3322
3393
  if (suppressCalendarDateScroll) return;
3323
3394
  // Scroll so calendar is almost at top and user sees the rest of the booking flow.
3324
3395
  // Dialog: scroll inside contentRef. Full-page: fall back to window scroll.
@@ -3406,8 +3477,12 @@ export function NewBookingFlow({
3406
3477
  />
3407
3478
  )}
3408
3479
 
3409
- {/* Select return time */}
3410
- {selectedAvailability && selectedAvailability.returnOptions && selectedAvailability.returnOptions.length > 0 && (
3480
+ {/* Select return time — wait for hydrated day details so summary refetches do not flash this block */}
3481
+ {selectedAvailabilityMatchesDate &&
3482
+ selectedAvailability &&
3483
+ !selectedAvailability.isSummary &&
3484
+ selectedAvailability.returnOptions &&
3485
+ selectedAvailability.returnOptions.length > 0 && (
3411
3486
  <ReturnTimeSelector
3412
3487
  returnOptions={returnOptionsWithFloor}
3413
3488
  selectedReturnOption={selectedReturnOptionWithFloor}
@@ -3438,7 +3513,10 @@ export function NewBookingFlow({
3438
3513
  : null}
3439
3514
 
3440
3515
  {/* Cancellation policy selection - all options from config, sorted by cheapest first. Also show when forced by promo. */}
3441
- {selectedAvailability && ((pricingConfig?.cancellationPolicies?.length ?? 0) > 0 || forcedCancellationPolicy) && (() => {
3516
+ {selectedAvailabilityMatchesDate &&
3517
+ selectedAvailability &&
3518
+ ((pricingConfig?.cancellationPolicies?.length ?? 0) > 0 || forcedCancellationPolicy) &&
3519
+ (() => {
3442
3520
  const sortedPolicies = [...(pricingConfig?.cancellationPolicies ?? [])].sort((a, b) => {
3443
3521
  const feeA = a.feeByCurrency[currency] ?? 0;
3444
3522
  const feeB = b.feeByCurrency[currency] ?? 0;
@@ -3446,6 +3524,7 @@ export function NewBookingFlow({
3446
3524
  });
3447
3525
  return (
3448
3526
  <div ref={cancellationPolicyRef}>
3527
+ {beforeCancellationPolicy}
3449
3528
  <CancellationPolicySelector
3450
3529
  policies={sortedPolicies}
3451
3530
  selectedPolicyId={cancellationPolicyId}
@@ -3454,6 +3533,7 @@ export function NewBookingFlow({
3454
3533
  t={t}
3455
3534
  onPolicySelect={setCancellationPolicyId}
3456
3535
  forcedPolicy={forcedCancellationPolicy}
3536
+ sectionLabel={flowUi?.cancellationPolicySectionLabel}
3457
3537
  />
3458
3538
  </div>
3459
3539
  );
@@ -3552,6 +3632,8 @@ export function NewBookingFlow({
3552
3632
  onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
3553
3633
  onLastNameChange={(v) => { setLastName(v); setError(''); }}
3554
3634
  onEmailChange={(v) => { setEmail(v); setError(''); }}
3635
+ phoneNumber={phoneNumber}
3636
+ onPhoneNumberChange={(v) => { setPhoneNumber(v); setError(''); }}
3555
3637
  readOnlyContactFields={false}
3556
3638
  pickupLocations={
3557
3639
  selectedDate && product.pickupLocations && product.pickupLocations.length > 0
@@ -324,6 +324,13 @@
324
324
  color: #b91c1c;
325
325
  }
326
326
 
327
+ .contactFieldHint {
328
+ margin: 0.625rem 0 0;
329
+ font-size: 0.75rem;
330
+ line-height: 1.35;
331
+ color: var(--booking-stone-500, #78716c);
332
+ }
333
+
327
334
  .addOnOptionBtn {
328
335
  display: flex;
329
336
  align-items: center;
@@ -60,6 +60,8 @@ import {
60
60
  } from '../../lib/booking/source-metadata';
61
61
  import type { VideoSources } from '../../constants/products';
62
62
  import type { BookingFlowUiOptions } from './booking-flow-ui';
63
+ import { optionalCheckoutContactFields } from '../../lib/booking/checkout-contact';
64
+ import { CheckoutOptionalPhoneFields } from './CheckoutOptionalPhoneFields';
63
65
  import type {
64
66
  ProviderDashboardChangeBookingPayload,
65
67
  ProviderDashboardInitialBooking,
@@ -193,6 +195,7 @@ export function PrivateShuttleBookingFlow({
193
195
  const [email, setEmail] = useState('');
194
196
  const [firstName, setFirstName] = useState('');
195
197
  const [lastName, setLastName] = useState('');
198
+ const [phoneNumber, setPhoneNumber] = useState('');
196
199
  const [pickupLocationId, setPickupLocationId] = useState<string | null>(
197
200
  () => initialBooking?.pickupLocationId ?? initialValues?.pickupLocationId ?? null
198
201
  );
@@ -1684,6 +1687,8 @@ export function PrivateShuttleBookingFlow({
1684
1687
  customerEmail: email || undefined,
1685
1688
  customerFirstName: firstName.trim() || undefined,
1686
1689
  customerLastName: lastName.trim() || undefined,
1690
+ ...optionalCheckoutContactFields(phoneNumber),
1691
+ ...optionalCheckoutContactFields(phoneNumber),
1687
1692
  currency,
1688
1693
  travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1689
1694
  pickupLocationId: pickupLocationId || undefined,
@@ -1724,6 +1729,8 @@ export function PrivateShuttleBookingFlow({
1724
1729
  customerEmail: email,
1725
1730
  customerFirstName: firstName.trim() || undefined,
1726
1731
  customerLastName: lastName.trim() || undefined,
1732
+ ...optionalCheckoutContactFields(phoneNumber),
1733
+ ...optionalCheckoutContactFields(phoneNumber),
1727
1734
  currency,
1728
1735
  reservationReference: reservation.reservationReference,
1729
1736
  travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
@@ -1804,6 +1811,7 @@ export function PrivateShuttleBookingFlow({
1804
1811
  customerEmail: email,
1805
1812
  customerFirstName: firstName.trim() || undefined,
1806
1813
  customerLastName: lastName.trim() || undefined,
1814
+ ...optionalCheckoutContactFields(phoneNumber),
1807
1815
  currency,
1808
1816
  reservationReference: reservation.reservationReference,
1809
1817
  travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
@@ -1835,6 +1843,8 @@ export function PrivateShuttleBookingFlow({
1835
1843
  customerEmail: email || undefined,
1836
1844
  customerFirstName: firstName.trim() || undefined,
1837
1845
  customerLastName: lastName.trim() || undefined,
1846
+ ...optionalCheckoutContactFields(phoneNumber),
1847
+ ...optionalCheckoutContactFields(phoneNumber),
1838
1848
  currency,
1839
1849
  travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1840
1850
  pickupLocationId: pickupLocationId || undefined,
@@ -1990,6 +2000,7 @@ export function PrivateShuttleBookingFlow({
1990
2000
  customerEmail: email || undefined,
1991
2001
  customerFirstName: firstName.trim() || undefined,
1992
2002
  customerLastName: lastName.trim() || undefined,
2003
+ ...optionalCheckoutContactFields(phoneNumber),
1993
2004
  currency,
1994
2005
  travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
1995
2006
  pickupLocationId: pickupLocationId || undefined,
@@ -2875,6 +2886,16 @@ export function PrivateShuttleBookingFlow({
2875
2886
  className={styles.input}
2876
2887
  />
2877
2888
  </div>
2889
+ <CheckoutOptionalPhoneFields
2890
+ idPrefix="shuttle"
2891
+ labelClassName={styles.sectionLabel}
2892
+ phoneNumber={phoneNumber}
2893
+ onPhoneNumberChange={(v) => {
2894
+ setPhoneNumber(v);
2895
+ setError('');
2896
+ }}
2897
+ t={t}
2898
+ />
2878
2899
  </>
2879
2900
  {pricingConfig?.cancellationPolicies &&
2880
2901
  pricingConfig.cancellationPolicies.length > 0 && (
@@ -107,6 +107,8 @@ export interface BookingFlowBaseProps {
107
107
  * a date and shuttle time are selected. Used by photo-first dependent add-on flows.
108
108
  */
109
109
  afterItinerary?: ReactNode | ((context: BookingFlowAfterItineraryContext) => ReactNode);
110
+ /** Optional note rendered immediately above shuttle cancellation policy options (e.g. photo DAP). */
111
+ beforeCancellationPolicy?: ReactNode;
110
112
  /**
111
113
  * Optional hook to insert add-on rows into the Build Itinerary box. Receives the
112
114
  * base itinerary from the selected shuttle and returns the display list to render.
@@ -67,6 +67,8 @@ export interface BookingFlowUiOptions {
67
67
  itineraryStickyTopOffsetPx?: number;
68
68
  /** Provider dashboard change flow: quote/override panel shown immediately before confirm CTA. */
69
69
  providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
70
+ /** Override the orange section heading above cancellation policy cards (e.g. photo-first shuttle flow). */
71
+ cancellationPolicySectionLabel?: string;
70
72
  }
71
73
 
72
74
  /**
@@ -273,12 +273,20 @@
273
273
  border-radius: 0.5rem;
274
274
  }
275
275
 
276
- .booking-flow-preflight input:focus {
276
+ .booking-flow-preflight input:focus:not(.react-international-phone-input) {
277
277
  outline: none;
278
278
  border-color: var(--booking-primary);
279
279
  box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2);
280
280
  }
281
281
 
282
+ /* Phone country widget: single focus ring on outer container, not inner tel input */
283
+ .booking-flow-preflight input.react-international-phone-input:focus,
284
+ .booking-flow-preflight input.react-international-phone-input:focus-visible {
285
+ outline: none !important;
286
+ border: none !important;
287
+ box-shadow: none !important;
288
+ }
289
+
282
290
  /**
283
291
  * Admin “custom receipt line” row: scoped preflight sets `input, button { padding: 0 }` with
284
292
  * higher specificity than Tailwind padding utilities, so insets must be restored here.
@@ -1012,9 +1020,69 @@
1012
1020
  border-top-color: var(--booking-stone-200, #e7e5e4) !important;
1013
1021
  }
1014
1022
 
1015
- /* ========== Input fields - padding ========== */
1016
- .booking-flow-preflight [class*="CheckoutForm_input"] {
1017
- padding: 0.875rem 1.25rem !important;
1023
+ /* ========== Input fields - padding (scope to <input> only — [class*="CheckoutForm_input"] also matches inputError) ========== */
1024
+ .booking-flow-preflight input[class*="CheckoutForm_input"] {
1025
+ padding: 0.5rem 0.625rem !important;
1026
+ font-size: 0.875rem !important;
1027
+ line-height: 1.25rem !important;
1028
+ background-color: var(--booking-stone-50, #fafaf9) !important;
1029
+ border: 1px solid var(--booking-stone-300, #d6d3d1) !important;
1030
+ border-radius: 0.5rem !important;
1031
+ }
1032
+
1033
+ .booking-flow-preflight input[class*="CheckoutForm_input"]:focus {
1034
+ border-color: var(--booking-primary, #059669) !important;
1035
+ background-color: #fff !important;
1036
+ outline: none !important;
1037
+ box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2) !important;
1038
+ }
1039
+
1040
+ .booking-flow-preflight [class*="CheckoutForm_checkoutPhoneInput"] :global(.react-international-phone-input-container) {
1041
+ border: 1px solid var(--booking-stone-300, #d6d3d1) !important;
1042
+ border-radius: 0.5rem !important;
1043
+ background-color: var(--booking-stone-50, #fafaf9) !important;
1044
+ box-shadow: none !important;
1045
+ }
1046
+
1047
+ .booking-flow-preflight [class*="CheckoutForm_checkoutPhoneInput"]:focus-within :global(.react-international-phone-input-container) {
1048
+ border-color: var(--booking-primary, #059669) !important;
1049
+ border-width: 1px !important;
1050
+ background-color: #fff !important;
1051
+ outline: none !important;
1052
+ box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2) !important;
1053
+ }
1054
+
1055
+ .booking-flow-preflight [class*="CheckoutForm_checkoutPhoneInput"] :global(.react-international-phone-input) {
1056
+ padding: 0.5rem 0.625rem 0.5rem 0.25rem !important;
1057
+ font-size: 0.875rem !important;
1058
+ border-radius: 0 !important;
1059
+ }
1060
+
1061
+ .booking-flow-preflight [class*="CheckoutForm_checkoutPhoneInput"]:focus-within :global(.react-international-phone-country-selector-button) {
1062
+ box-shadow: none !important;
1063
+ outline: none !important;
1064
+ }
1065
+
1066
+ /* Hint is a div (preflight zeros all `p` margins) */
1067
+ .booking-flow-preflight [class*="CheckoutForm_optionalPhoneFieldHint"],
1068
+ .booking-flow-root [class*="CheckoutForm_optionalPhoneFieldHint"] {
1069
+ margin: 0 !important;
1070
+ padding: 0.375rem 0.5rem 0 !important;
1071
+ font-size: 0.75rem !important;
1072
+ line-height: 1.4 !important;
1073
+ color: var(--booking-stone-500, #78716c) !important;
1074
+ }
1075
+
1076
+ .booking-flow-preflight [class*="CheckoutForm_inputError"],
1077
+ .booking-flow-root [class*="CheckoutForm_inputError"] {
1078
+ margin: 0.375rem 0 0 !important;
1079
+ padding: 0 !important;
1080
+ border: none !important;
1081
+ background: transparent !important;
1082
+ box-shadow: none !important;
1083
+ font-size: 0.75rem !important;
1084
+ line-height: 1.35 !important;
1085
+ color: #b91c1c !important;
1018
1086
  }
1019
1087
  /* Promo field: scope to <input> only — [class*="PromoCodeInput_input"] also matches inputWrap (…inputWrap…) and was drawing a second box */
1020
1088
  .booking-flow-preflight input[class*="PromoCodeInput_input"] {
@@ -10,7 +10,6 @@
10
10
  {
11
11
  "title": "30-minute session",
12
12
  "items": [
13
- "📸 20 to 25 minutes of photography time",
14
13
  "📍 2 Locations within walking distance",
15
14
  "🖼️ 25 professionally edited photos delivered in a personalized online gallery"
16
15
  ]
@@ -18,7 +17,6 @@
18
17
  {
19
18
  "title": "60-minute session",
20
19
  "items": [
21
- "📸 50 to 55 minutes of photography time",
22
20
  "📍 2-3 Locations within walking distance",
23
21
  "🖼️ 50 professionally edited photos delivered in a personalized online gallery"
24
22
  ]
@@ -26,7 +24,6 @@
26
24
  {
27
25
  "title": "90-minute session",
28
26
  "items": [
29
- "📸 80-90 mins of Photography Time",
30
27
  "📍 5 Locations within walking distance",
31
28
  "🖼️ 75 professionally edited photos delivered in a personalized online gallery"
32
29
  ]
@@ -7,27 +7,27 @@
7
7
  "title": "What's included",
8
8
  "content": [
9
9
  {
10
- "title": "30-minute session",
10
+ "title": "Alpine Vows - 1 hour",
11
11
  "items": [
12
- "📸 20 to 25 minutes of photography time",
13
- "📍 2 Locations within walking distance",
14
- "🖼️ 25 professionally edited photos delivered in a personalized online gallery"
12
+ "📸 60 minutes of photography time",
13
+ "📍 2-3 Locations within walking distance",
14
+ "🖼️ 60+ professionally edited photos delivered in a personalized online gallery"
15
15
  ]
16
16
  },
17
17
  {
18
- "title": "60-minute session",
18
+ "title": "The Summit Story - 4 hours",
19
19
  "items": [
20
- "📸 50 to 55 minutes of photography time",
21
- "📍 2-3 Locations within walking distance",
22
- "🖼️ 50 professionally edited photos delivered in a personalized online gallery"
20
+ "📸 3-4 hours of photography time",
21
+ "📍 Up to 3 Local Locations",
22
+ "🖼️ 100+ professionally edited photos delivered in a personalized online gallery"
23
23
  ]
24
24
  },
25
25
  {
26
- "title": "90-minute session",
26
+ "title": "The Grand Adventure - 8 hours",
27
27
  "items": [
28
- "📸 80-90 mins of Photography Time",
29
- "📍 5 Locations within walking distance",
30
- "🖼️ 75 professionally edited photos delivered in a personalized online gallery"
28
+ "📸 7-8 hours of photography time",
29
+ "📍 Up to 5 Local Locations",
30
+ "🖼️ 100+ professionally edited photos delivered in a personalized online gallery"
31
31
  ]
32
32
  },
33
33
  {