@ticketboothapp/booking 1.2.100 → 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 +310 -1
  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
@@ -7,27 +7,24 @@
7
7
  "title": "What's included",
8
8
  "content": [
9
9
  {
10
- "title": "30-minute session",
10
+ "title": "Essentials - 30 minutes",
11
11
  "items": [
12
- "📸 20 to 25 minutes of photography time",
13
12
  "📍 2 Locations within walking distance",
14
- "🖼️ 25 professionally edited photos delivered in a personalized online gallery"
13
+ "🖼️ 30 professionally edited photos delivered in a personalized online gallery"
15
14
  ]
16
15
  },
17
16
  {
18
- "title": "60-minute session",
17
+ "title": "Celebration - 60 minutes",
19
18
  "items": [
20
- "📸 50 to 55 minutes of photography time",
21
19
  "📍 2-3 Locations within walking distance",
22
- "🖼️ 50 professionally edited photos delivered in a personalized online gallery"
20
+ "🖼️ 60 professionally edited photos delivered in a personalized online gallery"
23
21
  ]
24
22
  },
25
23
  {
26
- "title": "90-minute session",
24
+ "title": "Immersive - 90 minutes",
27
25
  "items": [
28
- "📸 80-90 mins of Photography Time",
29
26
  "📍 5 Locations within walking distance",
30
- "🖼️ 75 professionally edited photos delivered in a personalized online gallery"
27
+ "🖼️ 90 professionally edited photos delivered in a personalized online gallery"
31
28
  ]
32
29
  },
33
30
  {
@@ -6,6 +6,11 @@
6
6
  {
7
7
  "productId": "p_qa5keidRoV6H",
8
8
  "optionIds": ["po_cSD2wlJfQqc0"],
9
+ "dependentAddOnProductIds": [
10
+ "dap_sM8pSzbFfU7P",
11
+ "dap_6tITweGzVyg8",
12
+ "dap_Xy3OCPIKQ7vr"
13
+ ],
9
14
  "display": {
10
15
  "path": "/moraine-lake-shuttle",
11
16
  "slug": "moraine-lake-sunrise-lake-louise-golden-hour",
@@ -20,6 +25,11 @@
20
25
  {
21
26
  "productId": "p_YKQsLmKhfYFp",
22
27
  "optionIds": ["po_82wA2gUZBZ9z"],
28
+ "dependentAddOnProductIds": [
29
+ "dap_sM8pSzbFfU7P",
30
+ "dap_6tITweGzVyg8",
31
+ "dap_Xy3OCPIKQ7vr"
32
+ ],
23
33
  "display": {
24
34
  "path": "/moraine-lake-shuttle",
25
35
  "slug": "moraine-lake-sunrise",
@@ -33,6 +43,11 @@
33
43
  {
34
44
  "productId": "p_CUJ4sVvaOkjk",
35
45
  "optionIds": ["po_ImWip6rbg0D6", "po_lOO7wz6uMus1", "po_fvrAfalgI673"],
46
+ "dependentAddOnProductIds": [
47
+ "dap_sM8pSzbFfU7P",
48
+ "dap_6tITweGzVyg8",
49
+ "dap_Xy3OCPIKQ7vr"
50
+ ],
36
51
  "display": {
37
52
  "path": "/moraine-lake-shuttle",
38
53
  "slug": "two-lakes-combo",
@@ -47,6 +62,11 @@
47
62
  {
48
63
  "productId": "p_wQHE15ITi7TS",
49
64
  "optionIds": ["po_FTLSMsahWKjM", "po_nbp2pFQo9AH7", "po_nzlCuOVJU181"],
65
+ "dependentAddOnProductIds": [
66
+ "dap_sM8pSzbFfU7P",
67
+ "dap_6tITweGzVyg8",
68
+ "dap_Xy3OCPIKQ7vr"
69
+ ],
50
70
  "display": {
51
71
  "path": "/moraine-lake-shuttle",
52
72
  "slug": "moraine-lake-adventure",
@@ -0,0 +1,8 @@
1
+ import { normalizeOptionalPhoneNumber } from './phone';
2
+
3
+ /** Optional phone from checkout — omitted when blank or country-code-only (e.g. "+1"). */
4
+ export function optionalCheckoutContactFields(phoneNumber: string) {
5
+ return {
6
+ customerPhoneNumber: normalizeOptionalPhoneNumber(phoneNumber),
7
+ };
8
+ }
@@ -102,6 +102,12 @@
102
102
  "firstNamePlaceholder": "Jane",
103
103
  "lastName": "Last Name",
104
104
  "lastNamePlaceholder": "Smith",
105
+ "phoneOptional": "Phone",
106
+ "phoneOptionalPlaceholder": "e.g. +1 403 555 0123",
107
+ "phoneOptionalHint": "For pickup and day-of updates",
108
+ "whatsAppOptional": "WhatsApp",
109
+ "whatsAppOptionalPlaceholder": "e.g. +1 403 555 0123",
110
+ "whatsAppOptionalHint": "Leave blank if same as phone",
105
111
  "dapNoLastNameOnFile": "We couldn’t find a last name on this booking in our system. Please contact us, or use Manage booking with the last name from your confirmation email.",
106
112
  "dapCancellationPolicyHeading": "Cancellation policy",
107
113
  "dapCancellationPolicyBody": "You may cancel this add-on for a full refund up to {days} {daysUnit} before your scheduled photo session. Cancellations made after that deadline may forfeit the entire add-on cost.",
@@ -102,6 +102,12 @@
102
102
  "firstNamePlaceholder": "Jeanne",
103
103
  "lastName": "Nom de famille",
104
104
  "lastNamePlaceholder": "Dupont",
105
+ "phoneOptional": "Téléphone",
106
+ "phoneOptionalPlaceholder": "p. ex. +1 403 555 0123",
107
+ "phoneOptionalHint": "Pour la prise en charge et les mises à jour le jour même",
108
+ "whatsAppOptional": "WhatsApp",
109
+ "whatsAppOptionalPlaceholder": "p. ex. +1 403 555 0123",
110
+ "whatsAppOptionalHint": "Laissez vide si identique au téléphone",
105
111
  "dapNoLastNameOnFile": "Nous n’avons pas trouvé de nom de famille pour cette réservation. Contactez-nous, ou utilisez Gérer la réservation avec le nom figurant sur votre courriel de confirmation.",
106
112
  "dapCancellationPolicyHeading": "Politique d’annulation",
107
113
  "dapCancellationPolicyBody": "Vous pouvez annuler cette option avec remboursement intégral jusqu’à {days} {daysUnit} avant votre séance photo prévue. Toute annulation après cette date peut entraîner la perte du montant total de l’option.",
@@ -0,0 +1,18 @@
1
+ /** Minimum digit count for a stored phone (country code + national number). */
2
+ const MIN_PHONE_DIGITS = 10;
3
+
4
+ /**
5
+ * Treat empty input and country-code-only values (e.g. default "+1") as no phone.
6
+ * Returns the trimmed E.164-style string when meaningful, otherwise undefined.
7
+ */
8
+ export function normalizeOptionalPhoneNumber(raw: string | undefined | null): string | undefined {
9
+ const trimmed = (raw ?? '').trim();
10
+ if (!trimmed) return undefined;
11
+ const digits = trimmed.replace(/\D/g, '');
12
+ if (digits.length < MIN_PHONE_DIGITS) return undefined;
13
+ return trimmed;
14
+ }
15
+
16
+ export function hasMeaningfulPhoneNumber(raw: string | undefined | null): boolean {
17
+ return normalizeOptionalPhoneNumber(raw) !== undefined;
18
+ }
@@ -355,26 +355,304 @@ function getAuthHeaders(): Record<string, string> {
355
355
 
356
356
  /** Idempotent GET retries: transient network errors and 502/503/504 (new trace span each attempt). */
357
357
  const BOOKING_GET_MAX_RETRIES = 2;
358
+ const BOOKING_ATTEMPT_HEADER = 'X-Booking-Attempt-Id';
359
+ const SLOW_REQUEST_THRESHOLD_MS = 2500;
360
+ const SUCCESS_TIMING_SAMPLE_RATE = 0.02;
361
+ const NETWORK_FAILURE_PROBE_TIMEOUT_MS = 1500;
358
362
 
359
363
  function sleep(ms: number): Promise<void> {
360
364
  return new Promise((resolve) => setTimeout(resolve, ms));
361
365
  }
362
366
 
367
+ function newBookingAttemptId(): string {
368
+ try {
369
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
370
+ return crypto.randomUUID();
371
+ }
372
+ } catch {
373
+ /* fall through */
374
+ }
375
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
376
+ }
377
+
378
+ function getEndpointFromUrl(url: string): string {
379
+ try {
380
+ const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
381
+ return parsed.pathname;
382
+ } catch {
383
+ return '';
384
+ }
385
+ }
386
+
387
+ function isBookingCriticalEndpoint(endpoint: string): boolean {
388
+ return (
389
+ endpoint === '/1/get-availabilities' ||
390
+ endpoint === '/1/products' ||
391
+ endpoint === '/1/reserve' ||
392
+ endpoint.startsWith('/1/companies/')
393
+ );
394
+ }
395
+
396
+ function getConnectionDiagnostics(): Record<string, number | string | boolean> | null {
397
+ if (typeof navigator === 'undefined') return null;
398
+ const connection = (
399
+ navigator as Navigator & {
400
+ connection?: {
401
+ effectiveType?: string;
402
+ rtt?: number;
403
+ downlink?: number;
404
+ saveData?: boolean;
405
+ };
406
+ }
407
+ ).connection;
408
+ if (!connection) return null;
409
+ return {
410
+ effectiveType: connection.effectiveType ?? 'unknown',
411
+ rtt: typeof connection.rtt === 'number' ? connection.rtt : -1,
412
+ downlink: typeof connection.downlink === 'number' ? connection.downlink : -1,
413
+ saveData: Boolean(connection.saveData),
414
+ };
415
+ }
416
+
417
+ function getBrowserDiagnostics(): Record<string, number | string | boolean> {
418
+ if (typeof window === 'undefined') return {};
419
+ return {
420
+ siteOrigin: window.location.origin,
421
+ siteProtocol: window.location.protocol,
422
+ isSecureContext: window.isSecureContext,
423
+ referrer: document.referrer || '',
424
+ };
425
+ }
426
+
427
+ function getAvailabilityQueryShape(url: string): Record<string, string | boolean | string[]> | null {
428
+ try {
429
+ const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
430
+ if (parsed.pathname !== '/1/get-availabilities') return null;
431
+ const params = parsed.searchParams;
432
+ return {
433
+ queryKeys: Array.from(params.keys()).sort(),
434
+ productId: params.get('productId') ?? '',
435
+ productOptionId: params.get('productOptionId') ?? '',
436
+ startDate: params.get('startDate') ?? '',
437
+ endDate: params.get('endDate') ?? '',
438
+ fromDateTime: params.get('fromDateTime') ?? '',
439
+ toDateTime: params.get('toDateTime') ?? '',
440
+ allOptions: params.get('allOptions') === 'true',
441
+ summary: params.get('summary') === 'true',
442
+ };
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ function latestResourceTiming(url: string): Record<string, number | string | boolean> | null {
449
+ if (typeof performance === 'undefined') return null;
450
+ const entry = performance
451
+ .getEntriesByName(url)
452
+ .filter((item): item is PerformanceResourceTiming => item.entryType === 'resource')
453
+ .at(-1);
454
+ if (!entry) return null;
455
+ return {
456
+ name: entry.name,
457
+ durationMs: Math.round(entry.duration),
458
+ startTimeMs: Math.round(entry.startTime),
459
+ fetchStartMs: Math.round(entry.fetchStart),
460
+ domainLookupStartMs: Math.round(entry.domainLookupStart),
461
+ domainLookupEndMs: Math.round(entry.domainLookupEnd),
462
+ connectStartMs: Math.round(entry.connectStart),
463
+ connectEndMs: Math.round(entry.connectEnd),
464
+ secureConnectionStartMs: Math.round(entry.secureConnectionStart),
465
+ requestStartMs: Math.round(entry.requestStart),
466
+ responseStartMs: Math.round(entry.responseStart),
467
+ responseEndMs: Math.round(entry.responseEnd),
468
+ transferSize: entry.transferSize,
469
+ encodedBodySize: entry.encodedBodySize,
470
+ decodedBodySize: entry.decodedBodySize,
471
+ timingRestricted: entry.responseStart === 0 && entry.responseEnd === 0,
472
+ };
473
+ }
474
+
475
+ function resourceTimingStatus(
476
+ timing: Record<string, number | string | boolean> | null
477
+ ): string {
478
+ if (!timing) return 'missing';
479
+ if (timing.timingRestricted === true) return 'timing_restricted';
480
+ if (typeof timing.responseStartMs === 'number' && timing.responseStartMs > 0) return 'response_started';
481
+ if (typeof timing.requestStartMs === 'number' && timing.requestStartMs > 0) return 'request_started';
482
+ if (typeof timing.connectStartMs === 'number' && timing.connectStartMs > 0) return 'connect_started';
483
+ if (typeof timing.fetchStartMs === 'number' && timing.fetchStartMs > 0) return 'fetch_started';
484
+ return 'unknown';
485
+ }
486
+
487
+ async function runNetworkFailureProbe(
488
+ label: string,
489
+ url: string,
490
+ init: RequestInit
491
+ ): Promise<Record<string, number | string | boolean | null>> {
492
+ const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
493
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
494
+ let timedOut = false;
495
+ const timeoutId =
496
+ controller && typeof window !== 'undefined'
497
+ ? window.setTimeout(() => {
498
+ timedOut = true;
499
+ controller.abort();
500
+ }, NETWORK_FAILURE_PROBE_TIMEOUT_MS)
501
+ : null;
502
+ try {
503
+ const response = await fetch(url, {
504
+ ...init,
505
+ cache: 'no-store',
506
+ signal: controller?.signal,
507
+ });
508
+ const timing = latestResourceTiming(url);
509
+ return {
510
+ label,
511
+ reachedFetchResponse: true,
512
+ ok: response.ok,
513
+ status: response.status,
514
+ responseType: response.type,
515
+ elapsedMs: Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt),
516
+ timedOut,
517
+ resourceTimingStatus: resourceTimingStatus(timing),
518
+ };
519
+ } catch (error) {
520
+ const timing = latestResourceTiming(url);
521
+ return {
522
+ label,
523
+ reachedFetchResponse: false,
524
+ ok: false,
525
+ status: null,
526
+ elapsedMs: Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt),
527
+ timedOut,
528
+ errorName: error instanceof Error ? error.name : 'UnknownError',
529
+ errorMessage: error instanceof Error ? error.message : String(error),
530
+ resourceTimingStatus: resourceTimingStatus(timing),
531
+ };
532
+ } finally {
533
+ if (timeoutId != null) window.clearTimeout(timeoutId);
534
+ }
535
+ }
536
+
537
+ async function collectNetworkFailureProbeResults(
538
+ endpoint: string,
539
+ correlationId: string,
540
+ traceparent: string
541
+ ): Promise<Array<Record<string, number | string | boolean | null>>> {
542
+ if (typeof window === 'undefined') return [];
543
+ const cacheBust = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
544
+ return Promise.all([
545
+ runNetworkFailureProbe(
546
+ 'same_origin_asset_head',
547
+ `${window.location.origin}/favicon.ico?tb_probe=${cacheBust}`,
548
+ { method: 'HEAD', mode: 'same-origin' }
549
+ ),
550
+ runNetworkFailureProbe(
551
+ 'api_telemetry_cors_post',
552
+ `${API_BASE}/1/client-telemetry`,
553
+ {
554
+ method: 'POST',
555
+ mode: 'cors',
556
+ headers: {
557
+ 'Content-Type': 'application/json',
558
+ [BOOKING_CORRELATION_HEADER]: correlationId,
559
+ traceparent,
560
+ },
561
+ body: JSON.stringify({
562
+ event: 'BOOKING_DIALOG_NETWORK_PROBE_PING',
563
+ endpoint,
564
+ correlationId,
565
+ traceparent,
566
+ apiBase: API_BASE,
567
+ pageUrl: window.location.href,
568
+ userAgent: window.navigator.userAgent,
569
+ online: window.navigator.onLine,
570
+ occurredAt: new Date().toISOString(),
571
+ ...getBrowserDiagnostics(),
572
+ }),
573
+ }
574
+ ),
575
+ ]);
576
+ }
577
+
578
+ function bookingRequestTelemetryContext(
579
+ url: string,
580
+ endpoint: string,
581
+ headers: Record<string, string>,
582
+ requestAttemptId: string,
583
+ attemptNumber: number,
584
+ elapsedMs?: number,
585
+ ): Record<string, unknown> {
586
+ const traceparent = headers.traceparent;
587
+ const traceId = traceparent ? traceIdFromTraceparent(traceparent) : undefined;
588
+ return {
589
+ endpoint,
590
+ method: 'GET',
591
+ correlationId: headers[BOOKING_CORRELATION_HEADER],
592
+ traceparent,
593
+ ...(traceId ? { traceId } : {}),
594
+ requestAttemptId,
595
+ attemptNumber,
596
+ maxRetries: BOOKING_GET_MAX_RETRIES,
597
+ requestUrlPath: endpoint,
598
+ requestMode: 'cors',
599
+ requestCache: 'default',
600
+ requestCredentials: 'same-origin',
601
+ requestUrlSearchLength: (() => {
602
+ try {
603
+ return new URL(url, window.location.href).search.length;
604
+ } catch {
605
+ return 0;
606
+ }
607
+ })(),
608
+ availabilityQueryShape: getAvailabilityQueryShape(url),
609
+ visibilityState: typeof document !== 'undefined' ? document.visibilityState : undefined,
610
+ pageAgeMs: typeof performance !== 'undefined' ? Math.round(performance.now()) : undefined,
611
+ connection: getConnectionDiagnostics(),
612
+ ...getBrowserDiagnostics(),
613
+ ...(elapsedMs != null ? { elapsedMs } : {}),
614
+ };
615
+ }
616
+
363
617
  async function fetchBookingGetWithRetry(
364
618
  url: string,
365
619
  extra?: Pick<RequestInit, 'cache' | 'signal'>,
366
620
  ): Promise<Response> {
367
621
  let lastErr: unknown;
622
+ const endpoint = getEndpointFromUrl(url);
368
623
  for (let attempt = 0; attempt <= BOOKING_GET_MAX_RETRIES; attempt++) {
369
624
  if (attempt > 0) {
370
625
  await sleep(250 * attempt);
371
626
  }
627
+ const requestAttemptId = newBookingAttemptId();
628
+ const headers: Record<string, string> = {
629
+ ...getAuthHeaders(),
630
+ [BOOKING_ATTEMPT_HEADER]: requestAttemptId,
631
+ };
632
+ const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
633
+ if (isBookingCriticalEndpoint(endpoint)) {
634
+ reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_STARTED', {
635
+ ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1),
636
+ });
637
+ }
372
638
  try {
373
639
  const res = await fetch(url, {
374
640
  ...extra,
375
641
  method: 'GET',
376
- headers: getAuthHeaders(),
642
+ headers,
377
643
  });
644
+ const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
645
+ if (
646
+ elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ||
647
+ (isBookingCriticalEndpoint(endpoint) && Math.random() < SUCCESS_TIMING_SAMPLE_RATE)
648
+ ) {
649
+ reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_TIMING', {
650
+ ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
651
+ httpStatus: res.status,
652
+ ok: res.ok,
653
+ resourceTiming: latestResourceTiming(url),
654
+ });
655
+ }
378
656
  if (res.ok) {
379
657
  return res;
380
658
  }
@@ -388,8 +666,36 @@ async function fetchBookingGetWithRetry(
388
666
  } catch (e) {
389
667
  lastErr = e;
390
668
  if (attempt < BOOKING_GET_MAX_RETRIES) {
669
+ const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
670
+ reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_RETRYING', {
671
+ ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
672
+ errorName: e instanceof Error ? e.name : undefined,
673
+ errorMessage: e instanceof Error ? e.message : String(e),
674
+ requestSignalAborted: Boolean(extra?.signal?.aborted),
675
+ });
391
676
  continue;
392
677
  }
678
+ const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
679
+ const resourceTiming = latestResourceTiming(url);
680
+ const probeResults = await collectNetworkFailureProbeResults(
681
+ endpoint,
682
+ headers[BOOKING_CORRELATION_HEADER],
683
+ headers.traceparent
684
+ );
685
+ reportBookingClientTelemetryEvent('BOOKING_DIALOG_NETWORK_DIAGNOSTIC', {
686
+ ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
687
+ errorName: e instanceof Error ? e.name : undefined,
688
+ errorMessage: e instanceof Error ? e.message : String(e),
689
+ errorCause: e instanceof Error && e.cause ? String(e.cause) : undefined,
690
+ requestSignalAborted: Boolean(extra?.signal?.aborted),
691
+ abortReason:
692
+ extra?.signal && 'reason' in extra.signal
693
+ ? String(extra.signal.reason ?? '')
694
+ : undefined,
695
+ resourceTiming,
696
+ resourceTimingStatus: resourceTimingStatus(resourceTiming),
697
+ probeResults,
698
+ });
393
699
  throw e;
394
700
  }
395
701
  }
@@ -1678,6 +1984,7 @@ export interface CreatePaymentIntentRequest {
1678
1984
  customerEmail?: string;
1679
1985
  customerFirstName?: string;
1680
1986
  customerLastName?: string;
1987
+ customerPhoneNumber?: string;
1681
1988
  currency?: string;
1682
1989
  reservationReference?: string;
1683
1990
  travelerHotel?: string;
@@ -1781,6 +2088,7 @@ export interface ConfirmFreeBookingRequest {
1781
2088
  customerEmail?: string;
1782
2089
  customerFirstName?: string;
1783
2090
  customerLastName?: string;
2091
+ customerPhoneNumber?: string;
1784
2092
  currency?: string;
1785
2093
  travelerHotel?: string;
1786
2094
  pickupLocationId?: string;
@@ -1860,6 +2168,7 @@ export interface ConfirmBookingWithoutPaymentRequest {
1860
2168
  customerEmail?: string;
1861
2169
  customerFirstName?: string;
1862
2170
  customerLastName?: string;
2171
+ customerPhoneNumber?: string;
1863
2172
  currency?: string;
1864
2173
  travelerHotel?: string;
1865
2174
  pickupLocationId?: string;
@@ -20,6 +20,11 @@ export interface ProductConfig {
20
20
  optionIds: string[];
21
21
  /** STANDARD or PRIVATE_SHUTTLE - affects availability date format. Default STANDARD. */
22
22
  productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
23
+ /**
24
+ * TicketBooth dependent add-on product ids this primary product supports at checkout.
25
+ * Used by photo-first flows to filter the shuttle picker.
26
+ */
27
+ dependentAddOnProductIds?: string[];
23
28
  display: ProductDisplayConfig;
24
29
  }
25
30
 
@@ -16,12 +16,16 @@ export interface DapDescriptionResult {
16
16
  paragraphs: string[];
17
17
  review?: { text: string; name: string };
18
18
  sections: { title: string; content: DapSectionContent }[];
19
+ learnMoreUrl?: string;
20
+ learnMoreLabel?: string;
19
21
  }
20
22
 
21
23
  interface DapDescriptionData {
22
24
  paragraphs: string[];
23
25
  review?: { text: string; name: string };
24
26
  sections: { title: string; content: DapSectionContent }[];
27
+ learnMoreUrl?: string;
28
+ learnMoreLabel?: string;
25
29
  }
26
30
 
27
31
  const EN: Record<PhotoDapSlug, DapDescriptionData> = {
@@ -46,5 +50,7 @@ export function getDapDescription(
46
50
  paragraphs: data.paragraphs ?? [],
47
51
  review: data.review,
48
52
  sections: data.sections ?? [],
53
+ learnMoreUrl: data.learnMoreUrl,
54
+ learnMoreLabel: data.learnMoreLabel,
49
55
  };
50
56
  }
@@ -305,6 +305,12 @@ export async function getDependentAddOnAvailability(
305
305
  }
306
306
  const payload = await parseJsonSafely(res);
307
307
  if (!res.ok) {
308
+ if (res.status === 404) {
309
+ throw new Error('We could not find that shuttle booking reference. Check the code and try again.');
310
+ }
311
+ if (res.status === 403) {
312
+ throw new Error('That last name does not match the shuttle booking. Check the spelling and try again.');
313
+ }
308
314
  throw new Error(messageFromPayload(payload, `Could not load times (HTTP ${res.status})`));
309
315
  }
310
316
  const data = payload as { data?: Record<string, unknown> } | null;