@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.
- package/package.json +1 -1
- package/src/components/booking/BookingDialog.module.css +9 -0
- package/src/components/booking/BookingProductGrid.module.css +11 -0
- package/src/components/booking/BookingProductGrid.tsx +54 -28
- package/src/components/booking/CancellationPolicySelector.tsx +4 -1
- package/src/components/booking/CheckoutForm.module.css +108 -3
- package/src/components/booking/CheckoutForm.tsx +13 -1
- package/src/components/booking/CheckoutOptionalPhoneFields.tsx +58 -0
- package/src/components/booking/DapTourDescription.tsx +9 -7
- package/src/components/booking/DependentAddOnBookingDialog.tsx +42 -7
- package/src/components/booking/NewBookingFlow.tsx +137 -55
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +7 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -0
- package/src/components/booking/booking-flow-types.ts +2 -0
- package/src/components/booking/booking-flow-ui.ts +2 -0
- package/src/components/booking/booking-flow.css +72 -4
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +0 -3
- package/src/data/dap-descriptions/session-elopements.en.json +12 -12
- package/src/data/dap-descriptions/session-proposals.en.json +6 -9
- package/src/data/products-config.json +20 -0
- package/src/lib/booking/checkout-contact.ts +8 -0
- package/src/lib/booking/i18n/messages/en.json +6 -0
- package/src/lib/booking/i18n/messages/fr.json +6 -0
- package/src/lib/booking/phone.ts +18 -0
- package/src/lib/booking-api.ts +131 -2
- package/src/lib/booking-types.ts +5 -0
- package/src/lib/dap-descriptions.ts +6 -0
- package/src/lib/dependent-add-on-api.ts +6 -0
- package/src/lib/photo-dap-config.ts +92 -15
- package/src/providers/dependent-add-on-dialog-provider.tsx +6 -0
- package/src/runtime/types.ts +2 -0
- package/src/strings/en.json +6 -6
|
@@ -7,27 +7,24 @@
|
|
|
7
7
|
"title": "What's included",
|
|
8
8
|
"content": [
|
|
9
9
|
{
|
|
10
|
-
"title": "
|
|
10
|
+
"title": "Essentials - 30 minutes",
|
|
11
11
|
"items": [
|
|
12
|
-
"📸 20 to 25 minutes of photography time",
|
|
13
12
|
"📍 2 Locations within walking distance",
|
|
14
|
-
"🖼️
|
|
13
|
+
"🖼️ 30 professionally edited photos delivered in a personalized online gallery"
|
|
15
14
|
]
|
|
16
15
|
},
|
|
17
16
|
{
|
|
18
|
-
"title": "
|
|
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
|
-
"🖼️
|
|
20
|
+
"🖼️ 60 professionally edited photos delivered in a personalized online gallery"
|
|
23
21
|
]
|
|
24
22
|
},
|
|
25
23
|
{
|
|
26
|
-
"title": "
|
|
24
|
+
"title": "Immersive - 90 minutes",
|
|
27
25
|
"items": [
|
|
28
|
-
"📸 80-90 mins of Photography Time",
|
|
29
26
|
"📍 5 Locations within walking distance",
|
|
30
|
-
"🖼️
|
|
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
|
+
}
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/lib/booking-types.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
104
|
+
/** Photo count line (e.g. 30 photos). */
|
|
96
105
|
photosLabel?: string;
|
|
97
|
-
/**
|
|
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.
|
package/src/runtime/types.ts
CHANGED
|
@@ -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`. */
|
package/src/strings/en.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
|
|
18
18
|
"partnerPortal": {
|
|
19
|
-
"pageTitle": "Partner
|
|
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": {
|