@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingFlow.tsx +12 -7
  3. package/src/components/booking/BookingDialog.tsx +9 -4
  4. package/src/components/booking/BookingProductGrid.module.css +22 -10
  5. package/src/components/booking/BookingProductGrid.tsx +2 -2
  6. package/src/components/booking/ChangeBookingDialog.tsx +3 -1
  7. package/src/components/booking/ChangeBookingFlow.tsx +1 -1
  8. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
  9. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
  10. package/src/components/booking/NewBookingFlow.tsx +6 -2
  11. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
  12. package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
  13. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
  14. package/src/components/booking/TicketSelector.module.css +8 -0
  15. package/src/components/booking/TicketSelector.tsx +8 -0
  16. package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
  17. package/src/components/booking/availability-cache-policy.ts +10 -0
  18. package/src/components/booking/booking-flow-types.ts +3 -0
  19. package/src/components/booking/booking-flow-ui.ts +10 -0
  20. package/src/components/booking/use-private-shuttle-availability.ts +5 -1
  21. package/src/components/booking/use-standard-booking-availability.ts +36 -10
  22. package/src/constants/pill-values.ts +0 -8
  23. package/src/constants/products.ts +2 -2
  24. package/src/data/product-descriptions/private-tour.en.json +1 -2
  25. package/src/index.ts +5 -0
  26. package/src/lib/booking/i18n/messages/en.json +1 -0
  27. package/src/lib/booking/i18n/messages/fr.json +1 -0
  28. package/src/lib/booking/partner-pricing-profile.ts +74 -0
  29. package/src/lib/booking/reservation-attempt.ts +138 -0
  30. package/src/lib/booking-api.ts +297 -71
  31. package/src/lib/env.ts +13 -0
  32. package/src/providers/booking-dialog-provider.tsx +3 -2
  33. package/src/public-partners.ts +12 -1
  34. package/src/runtime/types.ts +4 -0
  35. package/src/strings/en.json +1 -2
  36. package/src/strings/es.json +1 -2
  37. package/src/strings/fr.json +1 -2
  38. package/test/change-booking-helpers.test.ts +181 -1
  39. package/test/partner-pricing-profile.test.ts +46 -0
@@ -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
- return await res.json();
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: API_BASE,
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 = `${API_BASE}/1/client-telemetry`;
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: API_BASE,
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 getAuthHeaders(): Record<string, string> {
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
- if (parsed.pathname !== '/1/get-availabilities') return null;
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
- `${API_BASE}/1/client-telemetry`,
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: API_BASE,
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(companyId: string): Promise<Product[]> {
1001
+ export async function fetchProducts(
1002
+ companyId: string,
1003
+ options?: { productId?: string }
1004
+ ): Promise<Product[]> {
878
1005
  const endpoint = '/1/products';
879
- const url = `${API_BASE}${endpoint}?companyId=${encodeURIComponent(companyId)}`;
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
- `${API_BASE}/1/companies/${encodeURIComponent(companyId)}`,
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(`${API_BASE}/1/validate-promo?${params}`);
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(`${API_BASE}/1/get-promo-discount?${params}`);
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(`${API_BASE}/1/add-ons?${params}`);
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 = `${API_BASE}${endpoint}?${params}`;
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 reservePayload = withExplicitBookingSource(request);
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
- let err: { errorMessage?: string; error?: string; errorCode?: string };
2397
- try {
2398
- err = JSON.parse(text);
2399
- } catch {
2400
- err = { errorMessage: text || 'Failed to create reservation' };
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
- const debugMessage = err.errorMessage || err.error || 'Failed to create reservation';
2403
- const insufficientCapacity = isInsufficientCapacityApiError(err.errorCode, debugMessage);
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: err.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: err.errorCode ?? null,
2665
+ apiErrorCode: failure.code ?? null,
2419
2666
  },
2420
2667
  });
2421
- throw createUserError(endpoint, 'HTTP', debugMessage, err.errorCode);
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 (data.errorCode || data.errorMessage) {
2430
- const debugMessage = data.errorMessage || 'Failed to create reservation';
2431
- const insufficientCapacity = isInsufficientCapacityApiError(data.errorCode, debugMessage);
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: data.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: data.errorCode ?? null,
2688
+ apiErrorCode: failure.code ?? null,
2446
2689
  },
2447
2690
  });
2448
- throw createUserError(endpoint, 'APP_ERROR_200', debugMessage, data.errorCode);
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
- return {
2465
- reservationReference: raw.reservationReference,
2466
- reservationExpiration: expiration,
2467
- expiresAt: expiration,
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(),