@ticketboothapp/booking 1.2.180 → 1.2.182
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/AdminChangeBookingFlow.tsx +12 -7
- package/src/components/booking/BookingDialog.tsx +9 -4
- package/src/components/booking/BookingProductGrid.module.css +22 -10
- package/src/components/booking/BookingProductGrid.tsx +2 -2
- package/src/components/booking/ChangeBookingDialog.tsx +3 -1
- package/src/components/booking/ChangeBookingFlow.tsx +1 -1
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
- package/src/components/booking/NewBookingFlow.tsx +6 -2
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
- package/src/components/booking/TicketSelector.module.css +8 -0
- package/src/components/booking/TicketSelector.tsx +8 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
- package/src/components/booking/availability-cache-policy.ts +10 -0
- package/src/components/booking/booking-flow-types.ts +3 -0
- package/src/components/booking/booking-flow-ui.ts +10 -0
- package/src/components/booking/use-private-shuttle-availability.ts +5 -1
- package/src/components/booking/use-standard-booking-availability.ts +36 -10
- package/src/constants/pill-values.ts +0 -8
- package/src/constants/products.ts +2 -2
- package/src/data/product-descriptions/private-tour.en.json +1 -2
- package/src/index.ts +5 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking/partner-pricing-profile.ts +74 -0
- package/src/lib/booking/reservation-attempt.ts +138 -0
- package/src/lib/booking-api.ts +297 -71
- package/src/lib/env.ts +13 -0
- package/src/providers/booking-dialog-provider.tsx +3 -2
- package/src/public-partners.ts +12 -1
- package/src/runtime/types.ts +4 -0
- package/src/strings/en.json +1 -2
- package/src/strings/es.json +1 -2
- package/src/strings/fr.json +1 -2
- package/test/change-booking-helpers.test.ts +181 -1
- package/test/partner-pricing-profile.test.ts +46 -0
package/src/lib/booking-api.ts
CHANGED
|
@@ -22,16 +22,34 @@ import {
|
|
|
22
22
|
sanitizeBookingSourceUrl,
|
|
23
23
|
type BookingSourceMetadata,
|
|
24
24
|
} from './booking/source-metadata';
|
|
25
|
+
import {
|
|
26
|
+
getOrCreateReservationAttempt,
|
|
27
|
+
reservationIdempotencyEnabled,
|
|
28
|
+
type ReservationAttemptContext,
|
|
29
|
+
} from './booking/reservation-attempt';
|
|
25
30
|
|
|
26
|
-
const API_BASE = ENV.API_URL;
|
|
31
|
+
const API_BASE = ENV.API_URL.replace(/\/$/, '');
|
|
32
|
+
const BOOKING_READ_API_BASE = ENV.BOOKING_READ_API_URL.replace(/\/$/, '');
|
|
33
|
+
const BOOKING_GATEWAY_PREFIX = '/api/booking';
|
|
27
34
|
|
|
28
35
|
/** When set (e.g. booking-portal partner session), reserve/checkout use Bearer instead of Basic. */
|
|
29
36
|
let partnerPortalBookingJwtGetter: () => string | null = () => null;
|
|
37
|
+
let partnerPortalBookingAuthorizationFailureHandler: () => void = () => {};
|
|
38
|
+
|
|
39
|
+
/** Partner sessions retain their Bearer-authenticated read path. Public reads use the gateway. */
|
|
40
|
+
function bookingReadApiBase(): string {
|
|
41
|
+
return partnerPortalBookingJwtGetter() ? API_BASE : BOOKING_READ_API_BASE;
|
|
42
|
+
}
|
|
30
43
|
|
|
31
44
|
export function setPartnerPortalBookingJwtGetter(fn: () => string | null): void {
|
|
32
45
|
partnerPortalBookingJwtGetter = fn;
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** Called when TicketBooth rejects the current partner JWT so the portal can clear stale authority. */
|
|
49
|
+
export function setPartnerPortalBookingAuthorizationFailureHandler(fn: () => void): void {
|
|
50
|
+
partnerPortalBookingAuthorizationFailureHandler = fn;
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
interface ApiErrorPayload {
|
|
36
54
|
errorCode?: string;
|
|
37
55
|
errorMessage?: string;
|
|
@@ -42,15 +60,85 @@ function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
|
|
|
42
60
|
return typeof value === 'object' && value !== null;
|
|
43
61
|
}
|
|
44
62
|
|
|
63
|
+
function notifyPartnerPortalAuthorizationFailure(): void {
|
|
64
|
+
if (!partnerPortalBookingJwtGetter()) return;
|
|
65
|
+
try {
|
|
66
|
+
partnerPortalBookingAuthorizationFailureHandler();
|
|
67
|
+
} catch {
|
|
68
|
+
// Authentication cleanup must never mask the original API error.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRejectedAuthenticationPayload(value: unknown): value is ApiErrorPayload {
|
|
73
|
+
if (!isApiErrorPayload(value) || value.errorCode !== 'AUTHORIZATION_FAILURE') return false;
|
|
74
|
+
return value.errorMessage?.trim().toLowerCase() !== 'access denied';
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
async function parseJsonSafely(res: Response): Promise<unknown> {
|
|
46
78
|
try {
|
|
47
|
-
|
|
79
|
+
const payload: unknown = await res.json();
|
|
80
|
+
if (isRejectedAuthenticationPayload(payload)) {
|
|
81
|
+
notifyPartnerPortalAuthorizationFailure();
|
|
82
|
+
}
|
|
83
|
+
return payload;
|
|
48
84
|
} catch {
|
|
49
85
|
return null;
|
|
50
86
|
}
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
type BookingClientErrorClass = 'NETWORK' | 'HTTP' | 'APP_ERROR_200';
|
|
90
|
+
const TELEMETRY_DEDUPE_WINDOW_MS = 30_000;
|
|
91
|
+
const telemetryDedupeFallback = new Map<string, number>();
|
|
92
|
+
|
|
93
|
+
function telemetryDedupeKey(
|
|
94
|
+
eventName: string,
|
|
95
|
+
correlationId: string,
|
|
96
|
+
fields: Record<string, unknown>
|
|
97
|
+
): string | null {
|
|
98
|
+
if (
|
|
99
|
+
eventName === 'BOOKING_DIALOG_REQUEST_TIMING' ||
|
|
100
|
+
eventName === 'BOOKING_DIALOG_REQUEST_RETRYING' ||
|
|
101
|
+
eventName === 'BOOKING_DIALOG_NETWORK_PROBE_PING'
|
|
102
|
+
) return null;
|
|
103
|
+
const endpoint = typeof fields.endpoint === 'string' ? fields.endpoint : '';
|
|
104
|
+
if (!endpoint) return null;
|
|
105
|
+
const signature = [
|
|
106
|
+
correlationId,
|
|
107
|
+
eventName,
|
|
108
|
+
endpoint,
|
|
109
|
+
fields.errorClass,
|
|
110
|
+
fields.errorCode,
|
|
111
|
+
fields.httpStatus,
|
|
112
|
+
fields.errorName,
|
|
113
|
+
].map((value) => String(value ?? '')).join('|');
|
|
114
|
+
let hash = 2166136261;
|
|
115
|
+
for (let index = 0; index < signature.length; index += 1) {
|
|
116
|
+
hash ^= signature.charCodeAt(index);
|
|
117
|
+
hash = Math.imul(hash, 16777619);
|
|
118
|
+
}
|
|
119
|
+
return `tb_booking_telemetry_${(hash >>> 0).toString(16)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shouldEmitBookingTelemetry(
|
|
123
|
+
eventName: string,
|
|
124
|
+
correlationId: string,
|
|
125
|
+
fields: Record<string, unknown>
|
|
126
|
+
): boolean {
|
|
127
|
+
const key = telemetryDedupeKey(eventName, correlationId, fields);
|
|
128
|
+
if (!key) return true;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
try {
|
|
131
|
+
const previous = Number(sessionStorage.getItem(key));
|
|
132
|
+
if (Number.isFinite(previous) && now - previous < TELEMETRY_DEDUPE_WINDOW_MS) return false;
|
|
133
|
+
sessionStorage.setItem(key, String(now));
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
const previous = telemetryDedupeFallback.get(key);
|
|
137
|
+
if (previous != null && now - previous < TELEMETRY_DEDUPE_WINDOW_MS) return false;
|
|
138
|
+
telemetryDedupeFallback.set(key, now);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
54
142
|
|
|
55
143
|
/** Thrown by booking-api helpers; includes API error details for UX branching (e.g. capacity conflicts). */
|
|
56
144
|
export type BookingClientError = Error & {
|
|
@@ -88,7 +176,7 @@ function logBookingApiNetworkError(endpoint: string, err: unknown): void {
|
|
|
88
176
|
if (typeof window === 'undefined') return;
|
|
89
177
|
const details = {
|
|
90
178
|
endpoint,
|
|
91
|
-
apiBase:
|
|
179
|
+
apiBase: bookingReadApiBase(),
|
|
92
180
|
online: window.navigator.onLine,
|
|
93
181
|
userAgent: window.navigator.userAgent,
|
|
94
182
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -102,16 +190,18 @@ function reportBookingClientTelemetryEvent(
|
|
|
102
190
|
fields: Record<string, unknown>
|
|
103
191
|
): void {
|
|
104
192
|
if (typeof window === 'undefined') return;
|
|
105
|
-
const telemetryEndpoint = `${
|
|
193
|
+
const telemetryEndpoint = `${BOOKING_READ_API_BASE}/1/client-telemetry`;
|
|
106
194
|
const correlationId = getOrCreateBookingCorrelationId();
|
|
195
|
+
if (!shouldEmitBookingTelemetry(eventName, correlationId, fields)) return;
|
|
107
196
|
const traceparent = buildTraceparent();
|
|
108
197
|
const traceId = traceIdFromTraceparent(traceparent);
|
|
109
198
|
const event = {
|
|
110
199
|
event: eventName,
|
|
200
|
+
clientBuildId: ENV.BOOKING_CLIENT_BUILD_ID,
|
|
111
201
|
correlationId,
|
|
112
202
|
traceparent,
|
|
113
203
|
...(traceId ? { traceId } : {}),
|
|
114
|
-
apiBase:
|
|
204
|
+
apiBase: BOOKING_READ_API_BASE,
|
|
115
205
|
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
116
206
|
userAgent: window.navigator.userAgent,
|
|
117
207
|
online: window.navigator.onLine,
|
|
@@ -153,6 +243,11 @@ function reportClientFetchError(payload: {
|
|
|
153
243
|
});
|
|
154
244
|
}
|
|
155
245
|
|
|
246
|
+
function gatewayResponseMetadata(response: Response): Record<string, string> | undefined {
|
|
247
|
+
const requestId = response.headers.get('X-Booking-Gateway-Request-Id');
|
|
248
|
+
return requestId ? { gatewayRequestId: requestId } : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
156
251
|
function isInsufficientCapacityApiError(errorCode?: string, errorMessage?: string): boolean {
|
|
157
252
|
const msg = (errorMessage ?? '').toLowerCase();
|
|
158
253
|
return errorCode === 'VALIDATION_FAILURE' && msg.includes('insufficient capacity');
|
|
@@ -193,6 +288,8 @@ function createUserError(
|
|
|
193
288
|
? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
|
|
194
289
|
: bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
|
|
195
290
|
? 'This reservation has already been completed or closed. Refresh before trying again.'
|
|
291
|
+
: bookingApiErrorCode === 'RESERVATION_OUTCOME_UNKNOWN'
|
|
292
|
+
? 'We could not safely confirm whether your reservation was created. Please keep this page open and try recovery again; do not start another checkout yet.'
|
|
196
293
|
: `${getUserFacingMessage(endpoint)} (${supportCode})`;
|
|
197
294
|
const error = new Error(userMessage) as BookingClientError;
|
|
198
295
|
error.debugMessage = debugMessage;
|
|
@@ -355,8 +452,19 @@ function logBookingSourceDebug(
|
|
|
355
452
|
}
|
|
356
453
|
}
|
|
357
454
|
|
|
358
|
-
function
|
|
455
|
+
function isBookingGatewayUrl(url: string): boolean {
|
|
456
|
+
try {
|
|
457
|
+
return new URL(url, API_BASE).pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`);
|
|
458
|
+
} catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function getAuthHeaders(url?: string): Record<string, string> {
|
|
359
464
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
465
|
+
if (url && isBookingGatewayUrl(url)) {
|
|
466
|
+
return withBookingOutboundHeaders(headers);
|
|
467
|
+
}
|
|
360
468
|
const partnerJwt = partnerPortalBookingJwtGetter();
|
|
361
469
|
if (partnerJwt) {
|
|
362
470
|
headers['Authorization'] = `Bearer ${partnerJwt}`;
|
|
@@ -393,7 +501,9 @@ function newBookingAttemptId(): string {
|
|
|
393
501
|
function getEndpointFromUrl(url: string): string {
|
|
394
502
|
try {
|
|
395
503
|
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
|
|
396
|
-
return parsed.pathname
|
|
504
|
+
return parsed.pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`)
|
|
505
|
+
? parsed.pathname.slice(BOOKING_GATEWAY_PREFIX.length)
|
|
506
|
+
: parsed.pathname;
|
|
397
507
|
} catch {
|
|
398
508
|
return '';
|
|
399
509
|
}
|
|
@@ -442,7 +552,10 @@ function getBrowserDiagnostics(): Record<string, number | string | boolean> {
|
|
|
442
552
|
function getAvailabilityQueryShape(url: string): Record<string, string | boolean | string[]> | null {
|
|
443
553
|
try {
|
|
444
554
|
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
|
|
445
|
-
|
|
555
|
+
const endpoint = parsed.pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`)
|
|
556
|
+
? parsed.pathname.slice(BOOKING_GATEWAY_PREFIX.length)
|
|
557
|
+
: parsed.pathname;
|
|
558
|
+
if (endpoint !== '/1/get-availabilities') return null;
|
|
446
559
|
const params = parsed.searchParams;
|
|
447
560
|
return {
|
|
448
561
|
queryKeys: Array.from(params.keys()).sort(),
|
|
@@ -556,6 +669,8 @@ async function collectNetworkFailureProbeResults(
|
|
|
556
669
|
): Promise<Array<Record<string, number | string | boolean | null>>> {
|
|
557
670
|
if (typeof window === 'undefined') return [];
|
|
558
671
|
const cacheBust = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
672
|
+
const telemetryUrl = `${BOOKING_READ_API_BASE}/1/client-telemetry`;
|
|
673
|
+
const telemetryIsSameOrigin = new URL(telemetryUrl, window.location.href).origin === window.location.origin;
|
|
559
674
|
return Promise.all([
|
|
560
675
|
runNetworkFailureProbe(
|
|
561
676
|
'same_origin_asset_head',
|
|
@@ -563,11 +678,11 @@ async function collectNetworkFailureProbeResults(
|
|
|
563
678
|
{ method: 'HEAD', mode: 'same-origin' }
|
|
564
679
|
),
|
|
565
680
|
runNetworkFailureProbe(
|
|
566
|
-
'api_telemetry_cors_post',
|
|
567
|
-
|
|
681
|
+
telemetryIsSameOrigin ? 'booking_telemetry_same_origin_post' : 'api_telemetry_cors_post',
|
|
682
|
+
telemetryUrl,
|
|
568
683
|
{
|
|
569
684
|
method: 'POST',
|
|
570
|
-
mode: 'cors',
|
|
685
|
+
mode: telemetryIsSameOrigin ? 'same-origin' : 'cors',
|
|
571
686
|
headers: {
|
|
572
687
|
'Content-Type': 'application/json',
|
|
573
688
|
[BOOKING_CORRELATION_HEADER]: correlationId,
|
|
@@ -575,10 +690,11 @@ async function collectNetworkFailureProbeResults(
|
|
|
575
690
|
},
|
|
576
691
|
body: JSON.stringify({
|
|
577
692
|
event: 'BOOKING_DIALOG_NETWORK_PROBE_PING',
|
|
693
|
+
clientBuildId: ENV.BOOKING_CLIENT_BUILD_ID,
|
|
578
694
|
endpoint,
|
|
579
695
|
correlationId,
|
|
580
696
|
traceparent,
|
|
581
|
-
apiBase:
|
|
697
|
+
apiBase: BOOKING_READ_API_BASE,
|
|
582
698
|
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
583
699
|
userAgent: window.navigator.userAgent,
|
|
584
700
|
online: window.navigator.onLine,
|
|
@@ -610,7 +726,7 @@ function bookingRequestTelemetryContext(
|
|
|
610
726
|
attemptNumber,
|
|
611
727
|
maxRetries: BOOKING_GET_MAX_RETRIES,
|
|
612
728
|
requestUrlPath: endpoint,
|
|
613
|
-
requestMode: 'cors',
|
|
729
|
+
requestMode: isBookingGatewayUrl(url) ? 'same-origin' : 'cors',
|
|
614
730
|
requestCache: 'default',
|
|
615
731
|
requestCredentials: 'same-origin',
|
|
616
732
|
requestUrlSearchLength: (() => {
|
|
@@ -641,23 +757,22 @@ async function fetchBookingGetWithRetry(
|
|
|
641
757
|
}
|
|
642
758
|
const requestAttemptId = newBookingAttemptId();
|
|
643
759
|
const headers: Record<string, string> = {
|
|
644
|
-
...getAuthHeaders(),
|
|
760
|
+
...getAuthHeaders(url),
|
|
645
761
|
[BOOKING_ATTEMPT_HEADER]: requestAttemptId,
|
|
646
762
|
};
|
|
647
763
|
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
648
|
-
if (isBookingCriticalEndpoint(endpoint)) {
|
|
649
|
-
reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_STARTED', {
|
|
650
|
-
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1),
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
764
|
try {
|
|
654
765
|
const res = await fetch(url, {
|
|
655
766
|
...extra,
|
|
656
767
|
method: 'GET',
|
|
657
768
|
headers,
|
|
658
769
|
});
|
|
770
|
+
if (res.status === 401) {
|
|
771
|
+
notifyPartnerPortalAuthorizationFailure();
|
|
772
|
+
}
|
|
659
773
|
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
|
|
660
774
|
if (
|
|
775
|
+
!res.ok ||
|
|
661
776
|
elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ||
|
|
662
777
|
(isBookingCriticalEndpoint(endpoint) && Math.random() < SUCCESS_TIMING_SAMPLE_RATE)
|
|
663
778
|
) {
|
|
@@ -665,6 +780,9 @@ async function fetchBookingGetWithRetry(
|
|
|
665
780
|
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
|
|
666
781
|
httpStatus: res.status,
|
|
667
782
|
ok: res.ok,
|
|
783
|
+
sampleRate: !res.ok || elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ? 1 : SUCCESS_TIMING_SAMPLE_RATE,
|
|
784
|
+
slowThresholdMs: SLOW_REQUEST_THRESHOLD_MS,
|
|
785
|
+
...gatewayResponseMetadata(res),
|
|
668
786
|
resourceTiming: latestResourceTiming(url),
|
|
669
787
|
});
|
|
670
788
|
}
|
|
@@ -675,6 +793,12 @@ async function fetchBookingGetWithRetry(
|
|
|
675
793
|
attempt < BOOKING_GET_MAX_RETRIES &&
|
|
676
794
|
(res.status === 502 || res.status === 503 || res.status === 504)
|
|
677
795
|
) {
|
|
796
|
+
reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_RETRYING', {
|
|
797
|
+
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
|
|
798
|
+
httpStatus: res.status,
|
|
799
|
+
retryReason: 'retryable_http_status',
|
|
800
|
+
...gatewayResponseMetadata(res),
|
|
801
|
+
});
|
|
678
802
|
continue;
|
|
679
803
|
}
|
|
680
804
|
return res;
|
|
@@ -874,9 +998,16 @@ export interface PricingConfig {
|
|
|
874
998
|
cancellationPolicies?: CancellationPolicyOption[];
|
|
875
999
|
}
|
|
876
1000
|
|
|
877
|
-
export async function fetchProducts(
|
|
1001
|
+
export async function fetchProducts(
|
|
1002
|
+
companyId: string,
|
|
1003
|
+
options?: { productId?: string }
|
|
1004
|
+
): Promise<Product[]> {
|
|
878
1005
|
const endpoint = '/1/products';
|
|
879
|
-
const
|
|
1006
|
+
const params = new URLSearchParams({ companyId });
|
|
1007
|
+
if (options?.productId?.trim()) {
|
|
1008
|
+
params.set('productId', options.productId.trim());
|
|
1009
|
+
}
|
|
1010
|
+
const url = `${bookingReadApiBase()}${endpoint}?${params}`;
|
|
880
1011
|
let res: Response;
|
|
881
1012
|
try {
|
|
882
1013
|
res = await fetchBookingGetWithRetry(url);
|
|
@@ -900,6 +1031,7 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
|
|
|
900
1031
|
message: debugMessage,
|
|
901
1032
|
httpStatus: res.status,
|
|
902
1033
|
errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
|
|
1034
|
+
metadata: gatewayResponseMetadata(res),
|
|
903
1035
|
});
|
|
904
1036
|
throw createUserError(endpoint, 'HTTP', debugMessage);
|
|
905
1037
|
}
|
|
@@ -912,6 +1044,7 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
|
|
|
912
1044
|
errorClass: 'APP_ERROR_200',
|
|
913
1045
|
message: appError,
|
|
914
1046
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
1047
|
+
metadata: gatewayResponseMetadata(res),
|
|
915
1048
|
});
|
|
916
1049
|
throw createUserError(endpoint, 'APP_ERROR_200', appError);
|
|
917
1050
|
}
|
|
@@ -921,13 +1054,13 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
|
|
|
921
1054
|
}
|
|
922
1055
|
|
|
923
1056
|
export async function getProduct(productId: string, companyId: string): Promise<Product | null> {
|
|
924
|
-
const products = await fetchProducts(companyId);
|
|
1057
|
+
const products = await fetchProducts(companyId, { productId });
|
|
925
1058
|
return products.find((p) => p.productId === productId) ?? null;
|
|
926
1059
|
}
|
|
927
1060
|
|
|
928
1061
|
export async function getCompany(companyId: string): Promise<Company> {
|
|
929
1062
|
const res = await fetchBookingGetWithRetry(
|
|
930
|
-
`${
|
|
1063
|
+
`${bookingReadApiBase()}/1/companies/${encodeURIComponent(companyId)}`,
|
|
931
1064
|
);
|
|
932
1065
|
if (!res.ok) {
|
|
933
1066
|
const err = await res.json();
|
|
@@ -965,7 +1098,7 @@ export async function validatePromoCode(
|
|
|
965
1098
|
if (normalizedProductId) params.set('productId', normalizedProductId);
|
|
966
1099
|
if (hasOngoingDiscount === true) params.set('hasOngoingDiscount', 'true');
|
|
967
1100
|
if (dateTime?.trim()) params.set('dateTime', dateTime.trim());
|
|
968
|
-
const res = await fetchBookingGetWithRetry(`${
|
|
1101
|
+
const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/validate-promo?${params}`);
|
|
969
1102
|
if (!res.ok) {
|
|
970
1103
|
const err = await res.json();
|
|
971
1104
|
throw new Error(err.errorMessage || err.error || 'Failed to validate promo code');
|
|
@@ -1094,7 +1227,7 @@ export async function getPromoDiscount(
|
|
|
1094
1227
|
) {
|
|
1095
1228
|
params.set('legacyPromoNewAddOnSubtotal', String(bookingChange.legacyPromoNewAddOnSubtotal));
|
|
1096
1229
|
}
|
|
1097
|
-
const res = await fetchBookingGetWithRetry(`${
|
|
1230
|
+
const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/get-promo-discount?${params}`);
|
|
1098
1231
|
if (!res.ok) {
|
|
1099
1232
|
const err = await res.json();
|
|
1100
1233
|
throw new Error(err.errorMessage || err.error || 'Failed to get promo discount');
|
|
@@ -1114,7 +1247,7 @@ export async function getAddOns(
|
|
|
1114
1247
|
}
|
|
1115
1248
|
if (options?.preCheckout !== undefined) params.set('preCheckout', String(options.preCheckout));
|
|
1116
1249
|
if (options?.dateTime?.trim()) params.set('dateTime', options.dateTime.trim());
|
|
1117
|
-
const res = await fetchBookingGetWithRetry(`${
|
|
1250
|
+
const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/add-ons?${params}`);
|
|
1118
1251
|
if (!res.ok) {
|
|
1119
1252
|
const err = await res.json();
|
|
1120
1253
|
throw new Error(err.errorMessage || err.error || 'Failed to get add-ons');
|
|
@@ -1779,6 +1912,7 @@ export async function quoteAdminChangeBookingV2(
|
|
|
1779
1912
|
request: ChangeBookingQuoteRequest
|
|
1780
1913
|
): Promise<ChangeBookingQuoteResponse> {
|
|
1781
1914
|
const { bookingReference, lastName: _lastName, ...payload } = request;
|
|
1915
|
+
void _lastName;
|
|
1782
1916
|
const res = await fetch(
|
|
1783
1917
|
`${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/quote-v2`,
|
|
1784
1918
|
{
|
|
@@ -2093,7 +2227,7 @@ export async function getAvailabilities(
|
|
|
2093
2227
|
params.set('cancellationPolicyProfileId', cancellationPolicyProfileId);
|
|
2094
2228
|
}
|
|
2095
2229
|
const endpoint = '/1/get-availabilities';
|
|
2096
|
-
const url = `${
|
|
2230
|
+
const url = `${bookingReadApiBase()}${endpoint}?${params}`;
|
|
2097
2231
|
let res: Response;
|
|
2098
2232
|
try {
|
|
2099
2233
|
res = await fetchBookingGetWithRetry(url);
|
|
@@ -2117,6 +2251,7 @@ export async function getAvailabilities(
|
|
|
2117
2251
|
message: debugMessage,
|
|
2118
2252
|
httpStatus: res.status,
|
|
2119
2253
|
errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
|
|
2254
|
+
metadata: gatewayResponseMetadata(res),
|
|
2120
2255
|
});
|
|
2121
2256
|
throw createUserError(endpoint, 'HTTP', debugMessage);
|
|
2122
2257
|
}
|
|
@@ -2129,6 +2264,7 @@ export async function getAvailabilities(
|
|
|
2129
2264
|
errorClass: 'APP_ERROR_200',
|
|
2130
2265
|
message: appError,
|
|
2131
2266
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2267
|
+
metadata: gatewayResponseMetadata(res),
|
|
2132
2268
|
});
|
|
2133
2269
|
throw createUserError(endpoint, 'APP_ERROR_200', appError);
|
|
2134
2270
|
}
|
|
@@ -2191,6 +2327,8 @@ export interface ReserveRequest {
|
|
|
2191
2327
|
source?: string;
|
|
2192
2328
|
sourceMetadata?: BookingSourceMetadata;
|
|
2193
2329
|
source_metadata?: BookingSourceMetadata;
|
|
2330
|
+
/** One key per logical hold attempt; generated and retained by createReservation when enabled. */
|
|
2331
|
+
idempotencyKey?: string;
|
|
2194
2332
|
}
|
|
2195
2333
|
|
|
2196
2334
|
/** Safe subset of reserve payload for telemetry (no free-text traveler fields). */
|
|
@@ -2368,9 +2506,107 @@ export interface ReserveResponse {
|
|
|
2368
2506
|
currency?: string;
|
|
2369
2507
|
}
|
|
2370
2508
|
|
|
2509
|
+
interface ReserveApiEnvelope {
|
|
2510
|
+
data?: Partial<ReserveResponse> & { state?: string; retryAfterMs?: number };
|
|
2511
|
+
errorCode?: string;
|
|
2512
|
+
errorMessage?: string;
|
|
2513
|
+
error?: string;
|
|
2514
|
+
message?: string;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function parseReserveEnvelope(text: string): ReserveApiEnvelope {
|
|
2518
|
+
try {
|
|
2519
|
+
return JSON.parse(text) as ReserveApiEnvelope;
|
|
2520
|
+
} catch {
|
|
2521
|
+
return { errorMessage: text || 'Invalid response from server' };
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function normalizedReserveResponse(data: ReserveApiEnvelope['data']): ReserveResponse | null {
|
|
2526
|
+
if (!data?.reservationReference) return null;
|
|
2527
|
+
const expiration = data.reservationExpiration ?? data.expiresAt;
|
|
2528
|
+
if (!expiration) return null;
|
|
2529
|
+
return {
|
|
2530
|
+
reservationReference: data.reservationReference,
|
|
2531
|
+
reservationExpiration: expiration,
|
|
2532
|
+
expiresAt: expiration,
|
|
2533
|
+
totalAmount: data.totalAmount,
|
|
2534
|
+
currency: data.currency,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function reserveEnvelopeError(payload: ReserveApiEnvelope, fallback: string): {
|
|
2539
|
+
code?: string;
|
|
2540
|
+
message: string;
|
|
2541
|
+
} {
|
|
2542
|
+
return {
|
|
2543
|
+
code: payload.errorCode,
|
|
2544
|
+
message: payload.errorMessage || payload.error || payload.message || fallback,
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
async function recoverReservationAttempt(
|
|
2549
|
+
reservePayload: ReserveRequest,
|
|
2550
|
+
attempt: ReservationAttemptContext<ReserveRequest>
|
|
2551
|
+
): Promise<ReserveResponse> {
|
|
2552
|
+
const endpoint = '/1/reserve/status';
|
|
2553
|
+
const delays = [0, 250, 750, 1500];
|
|
2554
|
+
let lastMessage = 'The reservation outcome is still unknown.';
|
|
2555
|
+
for (const delay of delays) {
|
|
2556
|
+
if (delay > 0) {
|
|
2557
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2558
|
+
}
|
|
2559
|
+
let response: Response;
|
|
2560
|
+
try {
|
|
2561
|
+
response = await fetch(`${API_BASE}${endpoint}`, {
|
|
2562
|
+
method: 'POST',
|
|
2563
|
+
headers: getAuthHeaders(),
|
|
2564
|
+
body: JSON.stringify({ data: reservePayload }),
|
|
2565
|
+
});
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
lastMessage = error instanceof Error ? error.message : String(error);
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
const payload = parseReserveEnvelope(await response.text());
|
|
2571
|
+
const recovered = normalizedReserveResponse(payload.data);
|
|
2572
|
+
if (response.ok && recovered) {
|
|
2573
|
+
attempt.clear();
|
|
2574
|
+
return recovered;
|
|
2575
|
+
}
|
|
2576
|
+
if (response.status === 202 || payload.data?.state === 'IN_PROGRESS') {
|
|
2577
|
+
lastMessage = 'The reservation is still being processed.';
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const failure = reserveEnvelopeError(payload, `Recovery failed with HTTP ${response.status}`);
|
|
2581
|
+
lastMessage = failure.message;
|
|
2582
|
+
if (response.status === 422) {
|
|
2583
|
+
attempt.clear();
|
|
2584
|
+
throw createUserError(endpoint, 'HTTP', failure.message, failure.code);
|
|
2585
|
+
}
|
|
2586
|
+
if (response.status === 400 || response.status === 409) {
|
|
2587
|
+
throw createUserError(endpoint, 'HTTP', failure.message, failure.code);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
reportClientFetchError({
|
|
2591
|
+
endpoint,
|
|
2592
|
+
errorClass: 'NETWORK',
|
|
2593
|
+
message: lastMessage,
|
|
2594
|
+
errorCode: 'RESERVATION_OUTCOME_UNKNOWN',
|
|
2595
|
+
metadata: {
|
|
2596
|
+
failureKind: 'RESERVATION_RECOVERY_EXHAUSTED',
|
|
2597
|
+
reserveRequest: summarizeReserveRequestForTelemetry(reservePayload),
|
|
2598
|
+
},
|
|
2599
|
+
});
|
|
2600
|
+
throw createUserError(endpoint, 'NETWORK', lastMessage, 'RESERVATION_OUTCOME_UNKNOWN');
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2371
2603
|
export async function createReservation(request: ReserveRequest): Promise<ReserveResponse> {
|
|
2372
2604
|
const endpoint = '/1/reserve';
|
|
2373
|
-
const
|
|
2605
|
+
const baseReservePayload = withExplicitBookingSource(request);
|
|
2606
|
+
const attempt = reservationIdempotencyEnabled()
|
|
2607
|
+
? await getOrCreateReservationAttempt(baseReservePayload)
|
|
2608
|
+
: null;
|
|
2609
|
+
const reservePayload = attempt?.request ?? baseReservePayload;
|
|
2374
2610
|
let res: Response;
|
|
2375
2611
|
try {
|
|
2376
2612
|
res = await fetch(`${API_BASE}${endpoint}`, {
|
|
@@ -2380,6 +2616,9 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
|
|
|
2380
2616
|
});
|
|
2381
2617
|
} catch (err) {
|
|
2382
2618
|
const debugMessage = err instanceof Error ? err.message : String(err);
|
|
2619
|
+
if (attempt) {
|
|
2620
|
+
return recoverReservationAttempt(reservePayload, attempt);
|
|
2621
|
+
}
|
|
2383
2622
|
reportClientFetchError({
|
|
2384
2623
|
endpoint,
|
|
2385
2624
|
errorClass: 'NETWORK',
|
|
@@ -2392,15 +2631,23 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
|
|
|
2392
2631
|
throw createUserError(endpoint, 'NETWORK', debugMessage);
|
|
2393
2632
|
}
|
|
2394
2633
|
const text = await res.text();
|
|
2634
|
+
const payload = parseReserveEnvelope(text);
|
|
2635
|
+
if (res.status === 202 && attempt) {
|
|
2636
|
+
return recoverReservationAttempt(reservePayload, attempt);
|
|
2637
|
+
}
|
|
2395
2638
|
if (!res.ok) {
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2639
|
+
const failure = reserveEnvelopeError(payload, 'Failed to create reservation');
|
|
2640
|
+
const ambiguousTransportOutcome =
|
|
2641
|
+
failure.code === 'RESERVATION_OUTCOME_UNKNOWN' ||
|
|
2642
|
+
(res.status >= 500 && failure.code !== 'IDEMPOTENCY_UNAVAILABLE');
|
|
2643
|
+
if (attempt && ambiguousTransportOutcome) {
|
|
2644
|
+
return recoverReservationAttempt(reservePayload, attempt);
|
|
2401
2645
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2646
|
+
if (attempt && failure.code !== 'IDEMPOTENCY_CONFLICT') {
|
|
2647
|
+
attempt.clear();
|
|
2648
|
+
}
|
|
2649
|
+
const debugMessage = failure.message;
|
|
2650
|
+
const insufficientCapacity = isInsufficientCapacityApiError(failure.code, debugMessage);
|
|
2404
2651
|
reportClientFetchError({
|
|
2405
2652
|
endpoint,
|
|
2406
2653
|
errorClass: 'HTTP',
|
|
@@ -2408,66 +2655,45 @@ export async function createReservation(request: ReserveRequest): Promise<Reserv
|
|
|
2408
2655
|
? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
|
|
2409
2656
|
: debugMessage,
|
|
2410
2657
|
httpStatus: res.status,
|
|
2411
|
-
errorCode:
|
|
2658
|
+
errorCode: failure.code,
|
|
2412
2659
|
metadata: {
|
|
2413
2660
|
failureKind: insufficientCapacity
|
|
2414
2661
|
? 'RESERVE_INSUFFICIENT_CAPACITY'
|
|
2415
2662
|
: 'RESERVE_HTTP_ERROR',
|
|
2416
2663
|
reserveRequest: summarizeReserveRequestForTelemetry(request),
|
|
2417
2664
|
apiErrorMessage: debugMessage,
|
|
2418
|
-
apiErrorCode:
|
|
2665
|
+
apiErrorCode: failure.code ?? null,
|
|
2419
2666
|
},
|
|
2420
2667
|
});
|
|
2421
|
-
throw createUserError(endpoint, 'HTTP', debugMessage,
|
|
2422
|
-
}
|
|
2423
|
-
let data: { data?: ReserveResponse; errorCode?: string; errorMessage?: string };
|
|
2424
|
-
try {
|
|
2425
|
-
data = JSON.parse(text);
|
|
2426
|
-
} catch {
|
|
2427
|
-
throw new Error('Invalid response from server');
|
|
2668
|
+
throw createUserError(endpoint, 'HTTP', debugMessage, failure.code);
|
|
2428
2669
|
}
|
|
2429
|
-
if (
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2670
|
+
if (payload.errorCode || payload.errorMessage) {
|
|
2671
|
+
const failure = reserveEnvelopeError(payload, 'Failed to create reservation');
|
|
2672
|
+
attempt?.clear();
|
|
2673
|
+
const debugMessage = failure.message;
|
|
2674
|
+
const insufficientCapacity = isInsufficientCapacityApiError(failure.code, debugMessage);
|
|
2432
2675
|
reportClientFetchError({
|
|
2433
2676
|
endpoint,
|
|
2434
2677
|
errorClass: 'APP_ERROR_200',
|
|
2435
2678
|
message: insufficientCapacity
|
|
2436
2679
|
? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
|
|
2437
2680
|
: debugMessage,
|
|
2438
|
-
errorCode:
|
|
2681
|
+
errorCode: failure.code,
|
|
2439
2682
|
metadata: {
|
|
2440
2683
|
failureKind: insufficientCapacity
|
|
2441
2684
|
? 'RESERVE_INSUFFICIENT_CAPACITY'
|
|
2442
2685
|
: 'RESERVE_APP_ERROR_200',
|
|
2443
2686
|
reserveRequest: summarizeReserveRequestForTelemetry(request),
|
|
2444
2687
|
apiErrorMessage: debugMessage,
|
|
2445
|
-
apiErrorCode:
|
|
2688
|
+
apiErrorCode: failure.code ?? null,
|
|
2446
2689
|
},
|
|
2447
2690
|
});
|
|
2448
|
-
throw createUserError(endpoint, 'APP_ERROR_200', debugMessage,
|
|
2449
|
-
}
|
|
2450
|
-
if (!data.data?.reservationReference) {
|
|
2451
|
-
throw new Error('Invalid response: missing reservationReference');
|
|
2452
|
-
}
|
|
2453
|
-
const raw = data.data as {
|
|
2454
|
-
reservationReference: string;
|
|
2455
|
-
reservationExpiration?: string;
|
|
2456
|
-
expiresAt?: string;
|
|
2457
|
-
totalAmount?: number;
|
|
2458
|
-
currency?: string;
|
|
2459
|
-
};
|
|
2460
|
-
const expiration = raw.reservationExpiration ?? raw.expiresAt;
|
|
2461
|
-
if (!expiration) {
|
|
2462
|
-
throw new Error('Invalid response: missing reservation hold expiration');
|
|
2691
|
+
throw createUserError(endpoint, 'APP_ERROR_200', debugMessage, failure.code);
|
|
2463
2692
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
totalAmount: raw.totalAmount,
|
|
2469
|
-
currency: raw.currency,
|
|
2470
|
-
};
|
|
2693
|
+
const normalized = normalizedReserveResponse(payload.data);
|
|
2694
|
+
if (!normalized) throw new Error('Invalid response: missing reservationReference or hold expiration');
|
|
2695
|
+
attempt?.clear();
|
|
2696
|
+
return normalized;
|
|
2471
2697
|
}
|
|
2472
2698
|
|
|
2473
2699
|
export async function cancelReservation(reservationReference: string): Promise<void> {
|
package/src/lib/env.ts
CHANGED
|
@@ -24,6 +24,17 @@ const getApiUrl = (): string => {
|
|
|
24
24
|
return apiUrl;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Read-only booking traffic can use a same-origin gateway while transactional
|
|
29
|
+
* writes continue to use NEXT_PUBLIC_API_URL.
|
|
30
|
+
*/
|
|
31
|
+
const getBookingReadApiUrl = (): string => {
|
|
32
|
+
return process.env.NEXT_PUBLIC_BOOKING_READ_API_URL?.trim() || getApiUrl();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const getBookingClientBuildId = (): string =>
|
|
36
|
+
process.env.NEXT_PUBLIC_BOOKING_CLIENT_BUILD_ID?.trim() || 'source-unknown';
|
|
37
|
+
|
|
27
38
|
const getGoogleMapsApiKey = (): string => {
|
|
28
39
|
return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
|
|
29
40
|
};
|
|
@@ -92,6 +103,8 @@ export const isLocalhost = (): boolean =>
|
|
|
92
103
|
|
|
93
104
|
export const ENV = {
|
|
94
105
|
API_URL: getApiUrl(),
|
|
106
|
+
BOOKING_READ_API_URL: getBookingReadApiUrl(),
|
|
107
|
+
BOOKING_CLIENT_BUILD_ID: getBookingClientBuildId(),
|
|
95
108
|
GOOGLE_MAPS_API_KEY: getGoogleMapsApiKey(),
|
|
96
109
|
STRIPE_PUBLISHABLE_KEY: getStripePublishableKey(),
|
|
97
110
|
BASIC_AUTH: getBasicAuth(),
|