@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
@@ -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
+ }
@@ -358,6 +358,7 @@ const BOOKING_GET_MAX_RETRIES = 2;
358
358
  const BOOKING_ATTEMPT_HEADER = 'X-Booking-Attempt-Id';
359
359
  const SLOW_REQUEST_THRESHOLD_MS = 2500;
360
360
  const SUCCESS_TIMING_SAMPLE_RATE = 0.02;
361
+ const NETWORK_FAILURE_PROBE_TIMEOUT_MS = 1500;
361
362
 
362
363
  function sleep(ms: number): Promise<void> {
363
364
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -413,6 +414,16 @@ function getConnectionDiagnostics(): Record<string, number | string | boolean> |
413
414
  };
414
415
  }
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
+
416
427
  function getAvailabilityQueryShape(url: string): Record<string, string | boolean | string[]> | null {
417
428
  try {
418
429
  const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
@@ -461,6 +472,109 @@ function latestResourceTiming(url: string): Record<string, number | string | boo
461
472
  };
462
473
  }
463
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
+
464
578
  function bookingRequestTelemetryContext(
465
579
  url: string,
466
580
  endpoint: string,
@@ -481,6 +595,9 @@ function bookingRequestTelemetryContext(
481
595
  attemptNumber,
482
596
  maxRetries: BOOKING_GET_MAX_RETRIES,
483
597
  requestUrlPath: endpoint,
598
+ requestMode: 'cors',
599
+ requestCache: 'default',
600
+ requestCredentials: 'same-origin',
484
601
  requestUrlSearchLength: (() => {
485
602
  try {
486
603
  return new URL(url, window.location.href).search.length;
@@ -492,6 +609,7 @@ function bookingRequestTelemetryContext(
492
609
  visibilityState: typeof document !== 'undefined' ? document.visibilityState : undefined,
493
610
  pageAgeMs: typeof performance !== 'undefined' ? Math.round(performance.now()) : undefined,
494
611
  connection: getConnectionDiagnostics(),
612
+ ...getBrowserDiagnostics(),
495
613
  ...(elapsedMs != null ? { elapsedMs } : {}),
496
614
  };
497
615
  }
@@ -507,7 +625,7 @@ async function fetchBookingGetWithRetry(
507
625
  await sleep(250 * attempt);
508
626
  }
509
627
  const requestAttemptId = newBookingAttemptId();
510
- const headers = {
628
+ const headers: Record<string, string> = {
511
629
  ...getAuthHeaders(),
512
630
  [BOOKING_ATTEMPT_HEADER]: requestAttemptId,
513
631
  };
@@ -558,6 +676,12 @@ async function fetchBookingGetWithRetry(
558
676
  continue;
559
677
  }
560
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
+ );
561
685
  reportBookingClientTelemetryEvent('BOOKING_DIALOG_NETWORK_DIAGNOSTIC', {
562
686
  ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
563
687
  errorName: e instanceof Error ? e.name : undefined,
@@ -568,7 +692,9 @@ async function fetchBookingGetWithRetry(
568
692
  extra?.signal && 'reason' in extra.signal
569
693
  ? String(extra.signal.reason ?? '')
570
694
  : undefined,
571
- resourceTiming: latestResourceTiming(url),
695
+ resourceTiming,
696
+ resourceTimingStatus: resourceTimingStatus(resourceTiming),
697
+ probeResults,
572
698
  });
573
699
  throw e;
574
700
  }
@@ -1858,6 +1984,7 @@ export interface CreatePaymentIntentRequest {
1858
1984
  customerEmail?: string;
1859
1985
  customerFirstName?: string;
1860
1986
  customerLastName?: string;
1987
+ customerPhoneNumber?: string;
1861
1988
  currency?: string;
1862
1989
  reservationReference?: string;
1863
1990
  travelerHotel?: string;
@@ -1961,6 +2088,7 @@ export interface ConfirmFreeBookingRequest {
1961
2088
  customerEmail?: string;
1962
2089
  customerFirstName?: string;
1963
2090
  customerLastName?: string;
2091
+ customerPhoneNumber?: string;
1964
2092
  currency?: string;
1965
2093
  travelerHotel?: string;
1966
2094
  pickupLocationId?: string;
@@ -2040,6 +2168,7 @@ export interface ConfirmBookingWithoutPaymentRequest {
2040
2168
  customerEmail?: string;
2041
2169
  customerFirstName?: string;
2042
2170
  customerLastName?: string;
2171
+ customerPhoneNumber?: string;
2043
2172
  currency?: string;
2044
2173
  travelerHotel?: string;
2045
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;
@@ -41,41 +41,48 @@ const COUPLES_SESSION_PRODUCT_OPTIONS = [
41
41
  const ELOPEMENTS_SESSION_PRODUCT_OPTIONS = [
42
42
  {
43
43
  dependentAddOnProductOptionId: 'dapo_XTeJt09NyNfX',
44
- label: '30 minutes',
45
- photosLabel: '25 photos',
46
- startingAtLabel: 'Starting at $799',
44
+ name: 'Alpine Vows',
45
+ label: '1 hr',
46
+ photosLabel: '60+ photos',
47
+ startingAtLabel: '$999',
47
48
  },
48
49
  {
49
50
  dependentAddOnProductOptionId: 'dapo_Y8AxYt6Tjaam',
50
- label: '60 minutes',
51
- photosLabel: '50 photos',
52
- startingAtLabel: 'Starting at $999',
51
+ name: 'The Summit Story',
52
+ label: '4hrs',
53
+ photosLabel: '100+ photos',
54
+ startingAtLabel: '$2799',
53
55
  },
54
56
  {
55
57
  dependentAddOnProductOptionId: 'dapo_YdmxIiQPxEJg',
56
- label: '90 minutes',
57
- photosLabel: '75 photos',
58
- startingAtLabel: 'Starting at $1199',
58
+ name: 'The Grand Adventure',
59
+ label: '8hrs',
60
+ photosLabel: '100+ photos',
61
+ startingAtLabel: '$3999',
59
62
  },
60
63
  ] as const;
61
64
 
62
65
  const PROPOSALS_SESSION_PRODUCT_OPTIONS = [
63
66
  {
64
67
  dependentAddOnProductOptionId: 'dapo_SMRxjfDpIvwU',
68
+ name: 'Essentials',
65
69
  label: '30 minutes',
66
- photosLabel: '25 photos',
70
+ photosLabel: '30 photos',
67
71
  startingAtLabel: 'Starting at $799',
68
72
  },
69
73
  {
70
74
  dependentAddOnProductOptionId: 'dapo_FyMbFgrBU4L8',
75
+ name: 'Celebration',
71
76
  label: '60 minutes',
72
- photosLabel: '50 photos',
77
+ photosLabel: '60 photos',
73
78
  startingAtLabel: 'Starting at $999',
79
+ mostPopular: true,
74
80
  },
75
81
  {
76
82
  dependentAddOnProductOptionId: 'dapo_EOohfF0g2i1D',
83
+ name: 'Immersive',
77
84
  label: '90 minutes',
78
- photosLabel: '75 photos',
85
+ photosLabel: '90 photos',
79
86
  startingAtLabel: 'Starting at $1199',
80
87
  },
81
88
  ] as const;
@@ -90,16 +97,78 @@ export type PhotoDapSlug = (typeof PHOTO_DAP_SLUGS)[number];
90
97
 
91
98
  export type PhotoDapProductOption = {
92
99
  dependentAddOnProductOptionId: string;
93
- /** First line on the tile (e.g. duration). */
100
+ /** Package name (e.g. Essentials); when set, `label` is shown as the duration subheading. */
101
+ name?: string;
102
+ /** Duration subheading when `name` is set; otherwise the tile title. */
94
103
  label: string;
95
- /** Second line (e.g. edited photo count). */
104
+ /** Photo count line (e.g. 30 photos). */
96
105
  photosLabel?: string;
97
- /** Third line (e.g. "Starting at $399"). */
106
+ /** Price line (e.g. Starting at $799). */
98
107
  startingAtLabel?: string;
108
+ mostPopular?: boolean;
109
+ };
110
+
111
+ export function defaultPhotoDapProductOptionId(
112
+ options: readonly PhotoDapProductOption[]
113
+ ): string | undefined {
114
+ const popular = options.find((option) => option.mostPopular);
115
+ return popular?.dependentAddOnProductOptionId ?? options[0]?.dependentAddOnProductOptionId;
116
+ }
117
+
118
+ export type PhotoDapUpsellCardLines = {
119
+ startingPrice: string;
120
+ duration: string;
121
+ quantity: string;
99
122
  };
100
123
 
124
+ function parsePriceFromStartingAtLabel(label: string | undefined): number | null {
125
+ if (!label) return null;
126
+ const match = label.match(/\$([\d,]+)/);
127
+ if (!match) return null;
128
+ return Number.parseInt(match[1].replace(/,/g, ''), 10);
129
+ }
130
+
131
+ function formatPhotosSummary(options: readonly PhotoDapProductOption[]): string {
132
+ const parts = options
133
+ .map((option) => option.photosLabel?.replace(/\s*photos?\s*$/i, '').trim())
134
+ .filter((part): part is string => Boolean(part));
135
+ if (parts.length === 0) return '';
136
+ return `${parts.join(' / ')} photos`;
137
+ }
138
+
139
+ const UPSELL_STARTING_PRICE_BY_SLUG: Record<
140
+ PhotoDapSlug,
141
+ (minPrice: number) => string
142
+ > = {
143
+ 'session-couples-families-friends': (price) => `Sessions from $${price}`,
144
+ 'session-elopements': (price) => `Starting at $${price}`,
145
+ 'session-proposals': (price) => `Beginning at $${price}`,
146
+ };
147
+
148
+ /** Lines for manage-booking upsell cards — derived from the same options as the booking picker. */
149
+ export function getPhotoDapUpsellCardLines(slug: PhotoDapSlug): PhotoDapUpsellCardLines | null {
150
+ const catalog = getPhotoDapCatalog(slug);
151
+ const options = catalog?.productOptions;
152
+ if (!options?.length) return null;
153
+
154
+ const prices = options
155
+ .map((option) => parsePriceFromStartingAtLabel(option.startingAtLabel))
156
+ .filter((price): price is number => price != null);
157
+ const minPrice = prices.length > 0 ? Math.min(...prices) : null;
158
+
159
+ return {
160
+ startingPrice:
161
+ minPrice != null
162
+ ? UPSELL_STARTING_PRICE_BY_SLUG[slug](minPrice)
163
+ : (options[0]?.startingAtLabel ?? ''),
164
+ duration: options.map((option) => option.label).join(' / '),
165
+ quantity: formatPhotosSummary(options),
166
+ };
167
+ }
168
+
101
169
  export type PhotoDapCatalog = {
102
170
  dependentAddOnProductId: string;
171
+ receiptDisplayTitle: string;
103
172
  /** When set, that option is fixed (no picker). Env `NEXT_PUBLIC_DAP_PHOTO_SESSION_COUPLES_OPTION_ID` forces this for couples. */
104
173
  dependentAddOnProductOptionId?: string;
105
174
  /** When multiple entries and no fixed option id, the dialog shows a session-length control */
@@ -111,6 +180,8 @@ export type PhotoDapCatalog = {
111
180
  * Should match TicketBooth dependent add-on product config; availability API may override when present.
112
181
  */
113
182
  cancellationDaysBeforeSession: number;
183
+ learnMoreUrl?: string;
184
+ learnMoreLabel?: string;
114
185
  };
115
186
 
116
187
  /** Image sets for the dependent add-on dialog collage (photos only; no video). */
@@ -163,6 +234,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
163
234
  if (forcedOptionId) {
164
235
  return {
165
236
  dependentAddOnProductId: productId,
237
+ receiptDisplayTitle: 'Friends, family, and couples photography session',
166
238
  dependentAddOnProductOptionId: forcedOptionId,
167
239
  collageImageIds,
168
240
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -170,6 +242,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
170
242
  }
171
243
  return {
172
244
  dependentAddOnProductId: productId,
245
+ receiptDisplayTitle: 'Friends, family, and couples photography session',
173
246
  productOptions: [...COUPLES_SESSION_PRODUCT_OPTIONS],
174
247
  collageImageIds,
175
248
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -187,6 +260,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
187
260
  if (forcedOptionId) {
188
261
  return {
189
262
  dependentAddOnProductId: productId,
263
+ receiptDisplayTitle: 'Elopement photography session',
190
264
  dependentAddOnProductOptionId: forcedOptionId,
191
265
  collageImageIds,
192
266
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -194,6 +268,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
194
268
  }
195
269
  return {
196
270
  dependentAddOnProductId: productId,
271
+ receiptDisplayTitle: 'Elopement photography session',
197
272
  productOptions: [...ELOPEMENTS_SESSION_PRODUCT_OPTIONS],
198
273
  collageImageIds,
199
274
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -211,6 +286,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
211
286
  if (forcedOptionId) {
212
287
  return {
213
288
  dependentAddOnProductId: productId,
289
+ receiptDisplayTitle: 'Proposal photography session',
214
290
  dependentAddOnProductOptionId: forcedOptionId,
215
291
  collageImageIds,
216
292
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -218,6 +294,7 @@ export function getPhotoDapCatalog(slug: PhotoDapSlug): PhotoDapCatalog | null {
218
294
  }
219
295
  return {
220
296
  dependentAddOnProductId: productId,
297
+ receiptDisplayTitle: 'Proposal photography session',
221
298
  productOptions: [...PROPOSALS_SESSION_PRODUCT_OPTIONS],
222
299
  collageImageIds,
223
300
  cancellationDaysBeforeSession: DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION,
@@ -13,14 +13,18 @@ import type { PhotoDapSlug } from '../lib/photo-dap-config';
13
13
 
14
14
  export type DependentAddOnProductOptionChoice = {
15
15
  dependentAddOnProductOptionId: string;
16
+ name?: string;
16
17
  label: string;
17
18
  photosLabel?: string;
18
19
  startingAtLabel?: string;
20
+ mostPopular?: boolean;
19
21
  };
20
22
 
21
23
  export type DependentAddOnDialogOpenPayload = {
22
24
  /** Card title shown in dialog header */
23
25
  productDisplayTitle: string;
26
+ /** Customer-facing line label for receipts/payment summaries. */
27
+ receiptDisplayTitle?: string;
24
28
  dependentAddOnProductId: string;
25
29
  /** Fixed catalog option (no picker) */
26
30
  dependentAddOnProductOptionId?: string;
@@ -35,6 +39,8 @@ export type DependentAddOnDialogOpenPayload = {
35
39
  collageImageIds?: string[];
36
40
  /** Loads expandable copy from dap-descriptions */
37
41
  dapDescriptionSlug?: PhotoDapSlug;
42
+ learnMoreUrl?: string;
43
+ learnMoreLabel?: string;
38
44
  /**
39
45
  * From DAP catalog / TicketBooth product — days before the photo session for full-refund cancellation.
40
46
  * Availability API may override when it returns the same field.
@@ -53,6 +53,8 @@ export interface BookingRuntimeSlots {
53
53
  ImageModal: BookingSlotComponent;
54
54
  /** Optional override; package supplies a full default terms component when omitted. */
55
55
  TermsContent?: BookingSlotComponent;
56
+ /** Optional phone input with country selector (e.g. Via Via `PhoneInputWithCountry`). */
57
+ PhoneInput?: BookingSlotComponent;
56
58
  PlusIcon: ComponentType<SVGProps<SVGSVGElement>>;
57
59
  MinusIcon: ComponentType<SVGProps<SVGSVGElement>>;
58
60
  /** Via Via `ButtonHoverColor` enum from `@/components/button`. */
@@ -16,7 +16,7 @@
16
16
  },
17
17
 
18
18
  "partnerPortal": {
19
- "pageTitle": "Partner booking",
19
+ "pageTitle": "Partner Booking Portal",
20
20
  "tabBook": "Book",
21
21
  "tabBookings": "Your bookings",
22
22
  "tabLivePickups": "Live pickups",
@@ -49,7 +49,7 @@
49
49
  "bookTabAttributionConfirmLabelNoAgent": "I confirm the partner is correct.",
50
50
  "bookSuccessToast": "Booking made under partner {{partner}} with agent {{agent}}.",
51
51
  "bookSuccessToastNoAgent": "Booking made under partner {{partner}}.",
52
- "signInTitle": "Partner sign-in",
52
+ "signInTitle": "Partner Booking Portal sign-in",
53
53
  "signInDescription": "Use the email on file for your organization (partner contact, account email, or an agent payout email). We will email you a one-time code — no password to remember.",
54
54
  "signInEmailLabel": "Email",
55
55
  "signInSelectEmailPlaceholder": "Choose an email",
@@ -73,7 +73,7 @@
73
73
  "sessionLoading": "Restoring your session…",
74
74
  "bookingsLoading": "Loading your bookings…",
75
75
  "bookingsLoadError": "We could not load your bookings. Try again in a moment.",
76
- "bookingsEmpty": "No partner portal bookings yet. When you complete a booking while signed in, it will appear here.",
76
+ "bookingsEmpty": "No partner booking portal bookings yet. When you complete a booking while signed in, it will appear here.",
77
77
  "livePickupsEmptyToday": "No tour pickups scheduled for today.",
78
78
  "livePickupsGuestPageLink": "Open guest live pickups page",
79
79
  "bookingsColReference": "Reference",
@@ -191,9 +191,9 @@
191
191
  "orgAddAgentDuplicateName": "An agent with this name already exists. Choose a different name.",
192
192
  "orgNone": "—",
193
193
  "agentAddNewOption": "Add an agent…",
194
- "partnerLoginPageTitle": "Partner sign-in",
195
- "partnerLoginPageDescription": "Enter the email on file for your partner account. We will send you a one-time code to complete sign-in.",
196
- "backToPortal": "Back to partner portal"
194
+ "partnerLoginPageTitle": "Partner Booking Portal sign-in",
195
+ "partnerLoginPageDescription": "Enter the email on file for your partner booking account. We will send you a one-time code to complete sign-in.",
196
+ "backToPortal": "Back to partner booking portal"
197
197
  },
198
198
 
199
199
  "staffPortal": {