@ticketboothapp/booking 1.2.25-rc.0 → 1.2.27

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 (142) hide show
  1. package/package.json +11 -29
  2. package/src/components/booking/AddOnsSection.tsx +2 -2
  3. package/src/components/booking/AdminPaymentChoiceModal.tsx +1 -1
  4. package/src/components/booking/BookingDialog.tsx +31 -13
  5. package/src/components/booking/BookingFlow.tsx +32 -27
  6. package/src/components/booking/BookingFlowCollage.tsx +10 -6
  7. package/src/components/booking/BookingFlowPlaceholder.tsx +1 -1
  8. package/src/components/booking/BookingFlowPreview.tsx +18 -9
  9. package/src/components/booking/BookingProductGrid.tsx +55 -19
  10. package/src/components/booking/Calendar.module.css +19 -4
  11. package/src/components/booking/Calendar.tsx +13 -8
  12. package/src/components/booking/CancellationPolicySelector.tsx +2 -2
  13. package/src/components/booking/ChangeBookingDialog.tsx +22 -12
  14. package/src/components/booking/CheckoutForm.module.css +10 -0
  15. package/src/components/booking/CheckoutForm.tsx +10 -2
  16. package/src/components/booking/CheckoutModal.tsx +16 -14
  17. package/src/components/booking/DapFlowCollage.tsx +5 -2
  18. package/src/components/booking/DapTourDescription.tsx +4 -4
  19. package/src/components/booking/DependentAddOnBookingDialog.tsx +23 -16
  20. package/src/components/booking/DependentAddOnPaymentForm.tsx +10 -7
  21. package/src/components/booking/ItineraryBox.tsx +6 -6
  22. package/src/components/booking/ItineraryBuilder.tsx +1 -1
  23. package/src/components/booking/MealDrinkAddOnSelector.tsx +3 -3
  24. package/src/components/booking/PickupLocationSelector.tsx +20 -18
  25. package/src/components/booking/PickupTimeSelector.tsx +3 -3
  26. package/src/components/booking/PriceBreakdown.tsx +5 -5
  27. package/src/components/booking/PriceSummary.module.css +7 -0
  28. package/src/components/booking/PriceSummary.tsx +8 -7
  29. package/src/components/booking/PrivateShuttleBookingFlow.tsx +28 -19
  30. package/src/components/booking/PromoCodeInput.module.css +31 -25
  31. package/src/components/booking/PromoCodeInput.tsx +36 -24
  32. package/src/components/booking/ReturnTimeSelector.tsx +3 -3
  33. package/src/components/booking/TermsAcceptance.tsx +7 -2
  34. package/src/components/booking/TicketSelector.tsx +1 -1
  35. package/src/components/booking/TourDescription.tsx +11 -6
  36. package/src/components/booking/booking-flow.css +65 -4
  37. package/src/hooks/useBookingSourceMetadataFromLocation.ts +1 -1
  38. package/src/hooks/useIsBookingLaunchLive.ts +1 -1
  39. package/src/index.ts +26 -64
  40. package/src/providers/booking-dialog-provider.tsx +62 -53
  41. package/src/runtime/BookingHostContext.tsx +39 -0
  42. package/src/runtime/index.ts +13 -0
  43. package/src/runtime/types.ts +86 -0
  44. package/tsconfig.json +3 -5
  45. package/src/assets/icons/minus.svg +0 -7
  46. package/src/assets/icons/partner-logos/getyourguide.svg +0 -8
  47. package/src/assets/icons/plus.svg +0 -3
  48. package/src/colours.css +0 -23
  49. package/src/components/BookingDetails.module.css +0 -1591
  50. package/src/components/BookingDetails.tsx +0 -2264
  51. package/src/components/BookingWidget.tsx +0 -302
  52. package/src/components/ManageBookingView.tsx +0 -437
  53. package/src/components/PhoneInputWithCountry.module.css +0 -131
  54. package/src/components/PhoneInputWithCountry.tsx +0 -44
  55. package/src/components/PickupLocationDialog.module.css +0 -360
  56. package/src/components/PickupLocationDialog.tsx +0 -357
  57. package/src/components/PostBookingDependentAddOnUpsell.module.css +0 -174
  58. package/src/components/PostBookingDependentAddOnUpsell.tsx +0 -407
  59. package/src/components/button.css +0 -245
  60. package/src/components/button.tsx +0 -152
  61. package/src/components/colorable-svg.tsx +0 -29
  62. package/src/components/image.css +0 -29
  63. package/src/components/image.tsx +0 -113
  64. package/src/components/partner/PartnerBookingPage.module.css +0 -130
  65. package/src/components/partner/PartnerBookingPage.tsx +0 -390
  66. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +0 -45
  67. package/src/components/product-tag.module.css +0 -30
  68. package/src/components/product-tag.tsx +0 -34
  69. package/src/components/product-theme-pages/image-modal.tsx +0 -248
  70. package/src/components/product-theme-pages/photo-gallery.module.css +0 -200
  71. package/src/components/terms/TermsContent.tsx +0 -178
  72. package/src/components/value-pill.module.css +0 -59
  73. package/src/components/value-pill.tsx +0 -46
  74. package/src/constants/images.ts +0 -556
  75. package/src/constants/pill-values.ts +0 -210
  76. package/src/constants/products.ts +0 -155
  77. package/src/contexts/AvailabilitiesCacheContext.tsx +0 -125
  78. package/src/contexts/CompanyContext.tsx +0 -70
  79. package/src/data/dap-descriptions/session-couples-families-friends.en.json +0 -61
  80. package/src/data/dap-descriptions/session-elopements.en.json +0 -60
  81. package/src/data/dap-descriptions/session-proposals.en.json +0 -60
  82. package/src/data/product-descriptions/afternoon-delight.en.json +0 -35
  83. package/src/data/product-descriptions/emerald-lake-escape.en.json +0 -68
  84. package/src/data/product-descriptions/lake-louise-adventure.en.json +0 -74
  85. package/src/data/product-descriptions/moraine-lake-adventure.en.json +0 -78
  86. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +0 -65
  87. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +0 -64
  88. package/src/data/product-descriptions/private-tour.en.json +0 -80
  89. package/src/data/product-descriptions/two-lakes-combo.en.json +0 -65
  90. package/src/data/products-config.json +0 -101
  91. package/src/lib/analytics.ts +0 -197
  92. package/src/lib/booking/booking-source.ts +0 -51
  93. package/src/lib/booking/checkout-breakdown.ts +0 -69
  94. package/src/lib/booking/correlation-id.ts +0 -46
  95. package/src/lib/booking/i18n/config.ts +0 -21
  96. package/src/lib/booking/i18n/index.tsx +0 -144
  97. package/src/lib/booking/i18n/messages/en.json +0 -236
  98. package/src/lib/booking/i18n/messages/fr.json +0 -236
  99. package/src/lib/booking/itinerary-display.ts +0 -36
  100. package/src/lib/booking/itinerary-labels.ts +0 -70
  101. package/src/lib/booking/location-calculations.ts +0 -43
  102. package/src/lib/booking/location-utils.ts +0 -165
  103. package/src/lib/booking/map-utils.ts +0 -153
  104. package/src/lib/booking/marker-icons.ts +0 -113
  105. package/src/lib/booking/normalize-booking-product-id.ts +0 -21
  106. package/src/lib/booking/pickup-location-types.ts +0 -25
  107. package/src/lib/booking/places-api.ts +0 -154
  108. package/src/lib/booking/pricing.ts +0 -466
  109. package/src/lib/booking/product-option-id.ts +0 -35
  110. package/src/lib/booking/source-metadata.ts +0 -226
  111. package/src/lib/booking/sunday-week.ts +0 -14
  112. package/src/lib/booking/theme.ts +0 -83
  113. package/src/lib/booking/trace-context.ts +0 -62
  114. package/src/lib/booking/utils.ts +0 -9
  115. package/src/lib/booking-api.ts +0 -1793
  116. package/src/lib/booking-constants.ts +0 -23
  117. package/src/lib/booking-ref.ts +0 -13
  118. package/src/lib/booking-types.ts +0 -36
  119. package/src/lib/currency.ts +0 -81
  120. package/src/lib/dap-descriptions.ts +0 -50
  121. package/src/lib/dap-itinerary-preview.ts +0 -315
  122. package/src/lib/dependent-add-on-api.ts +0 -434
  123. package/src/lib/env.ts +0 -96
  124. package/src/lib/firebase.ts +0 -20
  125. package/src/lib/job-application-api.ts +0 -83
  126. package/src/lib/manage-booking-embed-print.ts +0 -16
  127. package/src/lib/manage-booking-post-checkout.ts +0 -68
  128. package/src/lib/photo-dap-config.ts +0 -228
  129. package/src/lib/photo-packages.ts +0 -75
  130. package/src/lib/pickup/map-utils.ts +0 -56
  131. package/src/lib/pickup/marker-icons.ts +0 -19
  132. package/src/lib/product-descriptions.ts +0 -66
  133. package/src/lib/products-config.ts +0 -73
  134. package/src/providers/dependent-add-on-dialog-provider.tsx +0 -105
  135. package/src/radius.css +0 -5
  136. package/src/spacing.css +0 -7
  137. package/src/strings/en.json +0 -1774
  138. package/src/strings/es.json +0 -1573
  139. package/src/strings/fr.json +0 -1573
  140. package/src/strings/index.js +0 -23
  141. package/src/text-style.css +0 -56
  142. package/src/utils/currency-converter.ts +0 -101
@@ -1,1793 +0,0 @@
1
- /**
2
- * API helpers for booking flow and manage-booking
3
- * Uses TicketBooth backend (api.ticketboothapp.com or localhost)
4
- */
5
-
6
- import {
7
- BOOKING_CORRELATION_HEADER,
8
- getOrCreateBookingCorrelationId,
9
- } from '@/lib/booking/correlation-id';
10
- import {
11
- buildTraceparent,
12
- traceIdFromTraceparent,
13
- withBookingOutboundHeaders,
14
- } from '@/lib/booking/trace-context';
15
- import {
16
- normalizeAvailabilityLookupId,
17
- normalizeBookingProductId,
18
- } from '@/lib/booking/normalize-booking-product-id';
19
- import { ENV } from '@/lib/env';
20
- import { DEFAULT_BOOKING_SOURCE, type BookingSourceMetadata } from '@/lib/booking/source-metadata';
21
-
22
- const API_BASE = ENV.API_URL;
23
-
24
- /** When set (e.g. booking-portal partner session), reserve/checkout use Bearer instead of Basic. */
25
- let partnerPortalBookingJwtGetter: () => string | null = () => null;
26
-
27
- export function setPartnerPortalBookingJwtGetter(fn: () => string | null): void {
28
- partnerPortalBookingJwtGetter = fn;
29
- }
30
-
31
- interface ApiErrorPayload {
32
- errorCode?: string;
33
- errorMessage?: string;
34
- error?: string;
35
- }
36
-
37
- function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
38
- return typeof value === 'object' && value !== null;
39
- }
40
-
41
- async function parseJsonSafely(res: Response): Promise<unknown> {
42
- try {
43
- return await res.json();
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- type BookingClientErrorClass = 'NETWORK' | 'HTTP' | 'APP_ERROR_200';
50
-
51
- /** Thrown by booking-api helpers; includes API error details for UX branching (e.g. capacity conflicts). */
52
- export type BookingClientError = Error & {
53
- debugMessage?: string;
54
- bookingApiErrorCode?: string;
55
- };
56
-
57
- function getUserFacingMessage(endpoint: string): string {
58
- switch (endpoint) {
59
- case '/1/get-availabilities':
60
- return 'Unable to load available times right now. Please try again.';
61
- case '/1/products':
62
- return 'Unable to load booking options right now. Please try again.';
63
- case '/1/reserve':
64
- return 'Unable to hold your booking right now. Please try again.';
65
- case '/checkout/payment-intent':
66
- return 'Unable to continue to payment right now. Please try again.';
67
- default:
68
- return 'Something went wrong while loading booking details. Please try again.';
69
- }
70
- }
71
-
72
- function buildSupportCode(endpoint: string, errorClass: BookingClientErrorClass): string {
73
- const endpointCode = endpoint
74
- .replace(/^\//, '')
75
- .replace(/[^a-zA-Z0-9]+/g, '-')
76
- .toUpperCase();
77
- return `BD-${errorClass}-${endpointCode}`;
78
- }
79
-
80
- function logBookingApiNetworkError(endpoint: string, err: unknown): void {
81
- if (typeof window === 'undefined') return;
82
- const details = {
83
- endpoint,
84
- apiBase: API_BASE,
85
- online: window.navigator.onLine,
86
- userAgent: window.navigator.userAgent,
87
- error: err instanceof Error ? err.message : String(err),
88
- };
89
- console.error('[booking-api] Network request failed', details);
90
- }
91
-
92
- /** Shared client → API `/1/client-telemetry` sender (never throws). */
93
- function reportBookingClientTelemetryEvent(
94
- eventName: string,
95
- fields: Record<string, unknown>
96
- ): void {
97
- if (typeof window === 'undefined') return;
98
- const telemetryEndpoint = `${API_BASE}/1/client-telemetry`;
99
- const correlationId = getOrCreateBookingCorrelationId();
100
- const traceparent = buildTraceparent();
101
- const traceId = traceIdFromTraceparent(traceparent);
102
- const event = {
103
- event: eventName,
104
- correlationId,
105
- traceparent,
106
- ...(traceId ? { traceId } : {}),
107
- apiBase: API_BASE,
108
- pageUrl: window.location.href,
109
- userAgent: window.navigator.userAgent,
110
- online: window.navigator.onLine,
111
- occurredAt: new Date().toISOString(),
112
- ...fields,
113
- };
114
- fetch(telemetryEndpoint, {
115
- method: 'POST',
116
- headers: {
117
- 'Content-Type': 'application/json',
118
- ...(correlationId ? { [BOOKING_CORRELATION_HEADER]: correlationId } : {}),
119
- traceparent,
120
- },
121
- body: JSON.stringify(event),
122
- keepalive: true,
123
- }).catch(() => {
124
- // Never throw from telemetry reporting.
125
- });
126
- }
127
-
128
- function reportClientFetchError(payload: {
129
- endpoint: string;
130
- errorClass: BookingClientErrorClass;
131
- message: string;
132
- httpStatus?: number;
133
- errorCode?: string;
134
- /** Structured context for dashboards (reserve failures, etc.). */
135
- metadata?: Record<string, unknown>;
136
- }): void {
137
- reportBookingClientTelemetryEvent('BOOKING_DIALOG_CLIENT_FETCH_ERROR', {
138
- endpoint: payload.endpoint,
139
- errorClass: payload.errorClass,
140
- message: payload.message,
141
- httpStatus: payload.httpStatus,
142
- errorCode: payload.errorCode,
143
- ...(payload.metadata && Object.keys(payload.metadata).length > 0
144
- ? { metadata: payload.metadata }
145
- : {}),
146
- });
147
- }
148
-
149
- function isInsufficientCapacityApiError(errorCode?: string, errorMessage?: string): boolean {
150
- const msg = (errorMessage ?? '').toLowerCase();
151
- return errorCode === 'VALIDATION_FAILURE' && msg.includes('insufficient capacity');
152
- }
153
-
154
- /**
155
- * After a reserve failure + availabilities reload, records what the UI believes inventory is now.
156
- * Pairs with `BOOKING_DIALOG_CLIENT_FETCH_ERROR` from the same correlation/session for debugging races.
157
- */
158
- export function reportReserveCapacityConflictClientContext(payload: {
159
- flow: 'standard_tour' | 'private_shuttle';
160
- productId?: string;
161
- selectedDate?: string | null;
162
- outboundDateTime?: string | null;
163
- outboundVacanciesAfterRefresh: number | null;
164
- returnVacanciesAfterRefresh: number | null;
165
- partySizeOrPassengers: number;
166
- hasReturnSelection?: boolean;
167
- returnAvailabilityId?: string | null;
168
- reloadAvailabilitiesSucceeded: boolean;
169
- reloadErrorMessage?: string;
170
- }): void {
171
- reportBookingClientTelemetryEvent('BOOKING_RESERVE_CAPACITY_CONFLICT_CONTEXT', {
172
- severity: 'critical_booking_path',
173
- issue: 'reserve_failed_inventory_race',
174
- ...payload,
175
- });
176
- }
177
-
178
- function createUserError(
179
- endpoint: string,
180
- errorClass: BookingClientErrorClass,
181
- debugMessage: string,
182
- bookingApiErrorCode?: string
183
- ): BookingClientError {
184
- const supportCode = buildSupportCode(endpoint, errorClass);
185
- const userMessage = `${getUserFacingMessage(endpoint)} (${supportCode})`;
186
- const error = new Error(userMessage) as BookingClientError;
187
- error.debugMessage = debugMessage;
188
- if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
189
- return error;
190
- }
191
-
192
- /** Reserve failed because inventory changed (race) — API returns VALIDATION_FAILURE + insufficient capacity message. */
193
- export function isInsufficientCapacityReserveError(err: unknown): boolean {
194
- if (!(err instanceof Error)) return false;
195
- const bookingErr = err as BookingClientError;
196
- const msg = (bookingErr.debugMessage ?? '').toLowerCase();
197
- return (
198
- bookingErr.bookingApiErrorCode === 'VALIDATION_FAILURE' &&
199
- msg.includes('insufficient capacity')
200
- );
201
- }
202
-
203
- /** Standard tour: explain which leg(s) are short after a fresh availabilities reload. */
204
- export function describeStandardTourCapacityConflictMessage(params: {
205
- partySize: number;
206
- outboundVacancies: number | null;
207
- returnVacancies: number | null;
208
- hasReturnSelection: boolean;
209
- }): string {
210
- const { partySize, outboundVacancies, returnVacancies, hasReturnSelection } = params;
211
- const people = partySize === 1 ? 'person' : 'people';
212
- const weakOut =
213
- outboundVacancies != null && outboundVacancies < partySize ? outboundVacancies : null;
214
- const weakRet =
215
- hasReturnSelection && returnVacancies != null && returnVacancies < partySize
216
- ? returnVacancies
217
- : null;
218
-
219
- if (weakOut != null || weakRet != null) {
220
- const bits: string[] = [];
221
- if (weakOut != null) {
222
- bits.push(
223
- weakOut === 0
224
- ? 'your outbound departure is now full'
225
- : `your outbound departure only has ${weakOut} spot${weakOut === 1 ? '' : 's'} left`
226
- );
227
- }
228
- if (weakRet != null) {
229
- bits.push(
230
- weakRet === 0
231
- ? 'your return trip is now full'
232
- : `your return trip only has ${weakRet} spot${weakRet === 1 ? '' : 's'} left`
233
- );
234
- }
235
- return `Not enough seats for ${partySize} ${people}: ${bits.join(
236
- '; '
237
- )}. Please choose another departure or return time, pick another date, or reduce the number of tickets.`;
238
- }
239
-
240
- return `We're sorry, our availability changed while you were making your selections — we could not reserve enough space for your group (${partySize} ${people}). Please choose another date or time, or reduce the number of tickets, then try again.`;
241
- }
242
-
243
- export function describePrivateShuttleCapacityConflictMessage(params: {
244
- passengersRequested: number;
245
- vacancies: number | null;
246
- }): string {
247
- const { passengersRequested, vacancies } = params;
248
- if (vacancies != null) {
249
- if (vacancies === 0) {
250
- return `This departure just sold out. Please pick another date or start time (you had ${passengersRequested} passenger${
251
- passengersRequested === 1 ? '' : 's'
252
- }).`;
253
- }
254
- if (vacancies < passengersRequested) {
255
- return `Only ${vacancies} spot${vacancies === 1 ? '' : 's'} remain for this departure, but you need space for ${passengersRequested} passenger${
256
- passengersRequested === 1 ? '' : 's'
257
- }. Try a different date or time, reduce the passenger count, or split the booking.`;
258
- }
259
- }
260
- return `We're sorry, our availability changed while you were making your selections — we could not reserve enough space for ${passengersRequested} passenger${
261
- passengersRequested === 1 ? '' : 's'
262
- }. Please pick another date or time or adjust the passenger count.`;
263
- }
264
-
265
- function toHttpErrorMessage(
266
- endpoint: string,
267
- status: number,
268
- payload: unknown,
269
- fallback: string
270
- ): string {
271
- if (isApiErrorPayload(payload)) {
272
- return payload.errorMessage || payload.error || `${fallback} (HTTP ${status})`;
273
- }
274
- return `${fallback} (HTTP ${status})`;
275
- }
276
-
277
- function toAppLevelErrorMessage(
278
- endpoint: string,
279
- payload: unknown,
280
- fallback: string
281
- ): string | null {
282
- if (!isApiErrorPayload(payload) || (!payload.errorCode && !payload.errorMessage && !payload.error)) {
283
- return null;
284
- }
285
- const message = payload.errorMessage || payload.error || fallback;
286
- const codeSuffix = payload.errorCode ? ` [${payload.errorCode}]` : '';
287
- return `${message}${codeSuffix} (endpoint: ${endpoint})`;
288
- }
289
-
290
- /**
291
- * Ensures checkout/reserve payloads always include `source` so the API never falls back to header-only auto-detect.
292
- * Booking UIs that compose attribution (main site, `/partner/…` pages, dedicated partner portal on `booking.*`) normally
293
- * send `source` (e.g. `PARTNER_PORTAL` or `WEBSITE`) and `sourceMetadata` explicitly; this only supplies
294
- * {@link DEFAULT_BOOKING_SOURCE} when `source` is missing.
295
- */
296
- function withExplicitBookingSource<T extends { source?: string }>(request: T): T & { source: string } {
297
- const s = request.source?.trim();
298
- if (s) return { ...request, source: s };
299
- return { ...request, source: DEFAULT_BOOKING_SOURCE };
300
- }
301
-
302
- function logBookingSourceDebug(
303
- stage: 'request' | 'response' | 'error',
304
- endpoint: string,
305
- payload: unknown
306
- ): void {
307
- if (typeof window === 'undefined') return;
308
- const prefix = `[booking-source-debug] ${stage} ${endpoint}`;
309
- try {
310
- if (payload && typeof payload === 'object') {
311
- const maybe = payload as {
312
- source?: string;
313
- sourceMetadata?: {
314
- pagePath?: string;
315
- pageUrl?: string;
316
- partnerId?: string;
317
- utmSource?: string;
318
- utmMedium?: string;
319
- utmCampaign?: string;
320
- };
321
- source_metadata?: {
322
- pagePath?: string;
323
- pageUrl?: string;
324
- partnerId?: string;
325
- utmSource?: string;
326
- utmMedium?: string;
327
- utmCampaign?: string;
328
- };
329
- };
330
- const md = maybe.sourceMetadata ?? maybe.source_metadata;
331
- if (md) {
332
- console.info(
333
- `${prefix} summary source=${maybe.source ?? 'n/a'} path=${md.pagePath ?? 'n/a'} partner=${md.partnerId ?? 'n/a'} utm_source=${md.utmSource ?? 'n/a'} utm_medium=${md.utmMedium ?? 'n/a'} utm_campaign=${md.utmCampaign ?? 'n/a'}`
334
- );
335
- }
336
- }
337
- console.info(`${prefix} payload\n${JSON.stringify(payload, null, 2)}`);
338
- } catch {
339
- console.info(prefix, payload);
340
- }
341
- }
342
-
343
- function getAuthHeaders(): Record<string, string> {
344
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
345
- const partnerJwt = partnerPortalBookingJwtGetter();
346
- if (partnerJwt) {
347
- headers['Authorization'] = `Bearer ${partnerJwt}`;
348
- return withBookingOutboundHeaders(headers);
349
- }
350
- if (ENV.BASIC_AUTH) {
351
- headers['Authorization'] = `Basic ${ENV.BASIC_AUTH}`;
352
- }
353
- return withBookingOutboundHeaders(headers);
354
- }
355
-
356
- /** Idempotent GET retries: transient network errors and 502/503/504 (new trace span each attempt). */
357
- const BOOKING_GET_MAX_RETRIES = 2;
358
-
359
- function sleep(ms: number): Promise<void> {
360
- return new Promise((resolve) => setTimeout(resolve, ms));
361
- }
362
-
363
- async function fetchBookingGetWithRetry(
364
- url: string,
365
- extra?: Pick<RequestInit, 'cache' | 'signal'>,
366
- ): Promise<Response> {
367
- let lastErr: unknown;
368
- for (let attempt = 0; attempt <= BOOKING_GET_MAX_RETRIES; attempt++) {
369
- if (attempt > 0) {
370
- await sleep(250 * attempt);
371
- }
372
- try {
373
- const res = await fetch(url, {
374
- ...extra,
375
- method: 'GET',
376
- headers: getAuthHeaders(),
377
- });
378
- if (res.ok) {
379
- return res;
380
- }
381
- if (
382
- attempt < BOOKING_GET_MAX_RETRIES &&
383
- (res.status === 502 || res.status === 503 || res.status === 504)
384
- ) {
385
- continue;
386
- }
387
- return res;
388
- } catch (e) {
389
- lastErr = e;
390
- if (attempt < BOOKING_GET_MAX_RETRIES) {
391
- continue;
392
- }
393
- throw e;
394
- }
395
- }
396
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
397
- }
398
-
399
- export interface PickupLocation {
400
- id: string;
401
- name: string;
402
- address: string;
403
- coordinates?: { lat: number; lng: number };
404
- pickupTimeOffsetMinutes?: number;
405
- travelMinutesFromDestination?: Record<string, number>;
406
- freeParking?: boolean;
407
- notes?: string;
408
- driverNotes?: string;
409
- }
410
-
411
- export interface ItineraryItem {
412
- destinationName: string;
413
- travelTimeFromPreviousHours: number;
414
- durationHours?: number;
415
- }
416
-
417
- export interface ItineraryOverride {
418
- startDate: string;
419
- endDate: string;
420
- itinerary: ItineraryItem[];
421
- }
422
-
423
- export interface ProductOption {
424
- optionId: string;
425
- name: string;
426
- description: string | null;
427
- pricing: Record<string, number>;
428
- status: string;
429
- mostPopular?: boolean;
430
- itinerary?: ItineraryItem[];
431
- itineraryOverrides?: ItineraryOverride[];
432
- /** Private Shuttle only: config for start times, duration, deposit, itinerary builder */
433
- privateShuttleConfig?: {
434
- baseDurationMinutes: number;
435
- suggestedStartTimes: string[];
436
- depositConfig?: { percentage?: number; fixedAmount?: number };
437
- balanceChargeDaysBefore?: number;
438
- itineraryBuilderConfig?: {
439
- optionBlacklist: string[];
440
- defaultDestinations?: string[];
441
- };
442
- };
443
- }
444
-
445
- export interface ItineraryBuilderDestination {
446
- id: string;
447
- label: string;
448
- latitude: number;
449
- longitude: number;
450
- }
451
-
452
- export interface ItineraryBuilder {
453
- destinations: ItineraryBuilderDestination[];
454
- }
455
-
456
- export interface Destination {
457
- name: string;
458
- latitude: number;
459
- longitude: number;
460
- }
461
-
462
- export interface Product {
463
- productId: string;
464
- companyId?: string;
465
- name: string;
466
- description: string | null;
467
- status: string;
468
- mostPopular?: boolean;
469
- productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
470
- options: ProductOption[];
471
- minPriceByCurrency?: Record<string, number>;
472
- pickupLocations?: PickupLocation[];
473
- destinations?: Destination[];
474
- /** Private Shuttle only: shared destinations for itinerary builder */
475
- itineraryBuilder?: ItineraryBuilder;
476
- }
477
-
478
- export interface AddOnVariant {
479
- id: string;
480
- label: string;
481
- priceAdjustment: number;
482
- }
483
-
484
- export interface AddOn {
485
- addOnId: string;
486
- name: string;
487
- description?: string | null;
488
- price: number;
489
- currency: string;
490
- preCheckout: boolean;
491
- postCheckout: boolean;
492
- variantType: 'none' | 'single_choice' | 'multi_quantity';
493
- variants: AddOnVariant[];
494
- productOptionIds: string[];
495
- }
496
-
497
- export interface CompanySettings {
498
- currency?: string;
499
- timezone?: string;
500
- }
501
-
502
- export interface Company {
503
- companyId: string;
504
- name: string;
505
- status: string;
506
- settings?: CompanySettings;
507
- }
508
-
509
- export const ItineraryStepType = {
510
- pickup: 'pickup',
511
- arrive: 'arrive',
512
- depart: 'depart',
513
- drop_off: 'drop_off',
514
- trip_end: 'trip_end',
515
- draft: 'draft',
516
- other: 'other',
517
- } as const;
518
- export type ItineraryStepType = (typeof ItineraryStepType)[keyof typeof ItineraryStepType];
519
-
520
- export interface ItineraryDisplayStep {
521
- stepType: ItineraryStepType;
522
- time: string;
523
- place?: string | null;
524
- }
525
-
526
- export interface RefundTierOption {
527
- hoursBefore: number;
528
- refundPercent: number;
529
- }
530
-
531
- export interface CancellationPolicyOption {
532
- id: string;
533
- label: string;
534
- feeByCurrency: Record<string, number>;
535
- refundTiers?: RefundTierOption[];
536
- changeWindowHoursBefore?: number | null;
537
- }
538
-
539
- export interface PricingConfig {
540
- /** Combined GST/tax + service charge on pre-tax subtotal (from backend pricing config). */
541
- taxRate: number;
542
- currenciesWithTaxIncluded: string[];
543
- fees?: Record<string, { feePerPerson: number; description?: string }>;
544
- feesByCurrency?: Record<string, Record<string, number>>;
545
- exchangeRates?: Record<string, number>;
546
- cancellationPolicies?: CancellationPolicyOption[];
547
- }
548
-
549
- export async function fetchProducts(companyId: string): Promise<Product[]> {
550
- const endpoint = '/1/products';
551
- const url = `${API_BASE}${endpoint}?companyId=${encodeURIComponent(companyId)}`;
552
- let res: Response;
553
- try {
554
- res = await fetchBookingGetWithRetry(url);
555
- } catch (err) {
556
- logBookingApiNetworkError(endpoint, err);
557
- const debugMessage = err instanceof Error ? err.message : String(err);
558
- reportClientFetchError({
559
- endpoint,
560
- errorClass: 'NETWORK',
561
- message: debugMessage,
562
- });
563
- throw createUserError(endpoint, 'NETWORK', debugMessage);
564
- }
565
-
566
- if (!res.ok) {
567
- const errPayload = await parseJsonSafely(res);
568
- const debugMessage = toHttpErrorMessage(endpoint, res.status, errPayload, 'Failed to fetch products');
569
- reportClientFetchError({
570
- endpoint,
571
- errorClass: 'HTTP',
572
- message: debugMessage,
573
- httpStatus: res.status,
574
- errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
575
- });
576
- throw createUserError(endpoint, 'HTTP', debugMessage);
577
- }
578
-
579
- const data = await parseJsonSafely(res);
580
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to fetch products');
581
- if (appError) {
582
- reportClientFetchError({
583
- endpoint,
584
- errorClass: 'APP_ERROR_200',
585
- message: appError,
586
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
587
- });
588
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
589
- }
590
-
591
- const products = (data as { data?: { products?: Product[] } } | null)?.data?.products;
592
- return products ?? [];
593
- }
594
-
595
- export async function getProduct(productId: string, companyId: string): Promise<Product | null> {
596
- const products = await fetchProducts(companyId);
597
- return products.find((p) => p.productId === productId) ?? null;
598
- }
599
-
600
- export async function getCompany(companyId: string): Promise<Company> {
601
- const res = await fetchBookingGetWithRetry(
602
- `${API_BASE}/1/companies/${encodeURIComponent(companyId)}`,
603
- );
604
- if (!res.ok) {
605
- const err = await res.json();
606
- throw new Error(err.errorMessage || err.error || 'Failed to get company');
607
- }
608
- const data = await res.json();
609
- return data.data?.company ?? null;
610
- }
611
-
612
- export interface ValidatePromoResponse {
613
- valid: boolean;
614
- name?: string;
615
- error?: string;
616
- /** When set, this promo forces the user to use this cancellation policy. Frontend should auto-select and optionally hide the selector. */
617
- forcedCancellationPolicyId?: string | null;
618
- /** Label for the forced policy (for display when policy has show_at_checkout=false). */
619
- forcedCancellationPolicyLabel?: string | null;
620
- /** Refund tiers for the forced policy (for display). */
621
- forcedCancellationPolicyRefundTiers?: RefundTierOption[];
622
- /** Change window in hours before booking (for "make changes x days before" when no refund tiers). */
623
- forcedChangeWindowHoursBefore?: number | null;
624
- }
625
-
626
- export async function validatePromoCode(
627
- promoCode: string,
628
- companyId: string,
629
- productId?: string,
630
- hasOngoingDiscount?: boolean
631
- ): Promise<ValidatePromoResponse> {
632
- const params = new URLSearchParams({ promoCode: promoCode.trim(), companyId });
633
- const normalizedProductId = productId?.trim()
634
- ? normalizeBookingProductId(productId)
635
- : '';
636
- if (normalizedProductId) params.set('productId', normalizedProductId);
637
- if (hasOngoingDiscount === true) params.set('hasOngoingDiscount', 'true');
638
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/validate-promo?${params}`);
639
- if (!res.ok) {
640
- const err = await res.json();
641
- throw new Error(err.errorMessage || err.error || 'Failed to validate promo code');
642
- }
643
- const data = await res.json();
644
- return data.data ?? { valid: false, error: 'Invalid response' };
645
- }
646
-
647
- export interface GetPromoDiscountResponse {
648
- discount: number;
649
- currency: string;
650
- isGiftCard?: boolean;
651
- isVoucher?: boolean;
652
- }
653
-
654
- export async function getPromoDiscount(
655
- promoCode: string,
656
- companyId: string,
657
- productId: string,
658
- optionId: string,
659
- currency: string,
660
- items: Array<{ category: string; qty: number }>,
661
- dateTime?: string,
662
- subtotal?: number
663
- ): Promise<GetPromoDiscountResponse> {
664
- const itemsStr = items.map((i) => `${i.category}:${i.qty}`).join(',');
665
- const params = new URLSearchParams({
666
- promoCode: promoCode.trim(),
667
- companyId,
668
- productId: normalizeBookingProductId(productId),
669
- optionId: normalizeBookingProductId(optionId),
670
- currency,
671
- items: itemsStr,
672
- });
673
- if (dateTime) params.set('dateTime', dateTime);
674
- if (subtotal != null && subtotal > 0) params.set('subtotal', String(subtotal));
675
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/get-promo-discount?${params}`);
676
- if (!res.ok) {
677
- const err = await res.json();
678
- throw new Error(err.errorMessage || err.error || 'Failed to get promo discount');
679
- }
680
- const data = await res.json();
681
- return data.data ?? { discount: 0, currency, isGiftCard: false, isVoucher: false };
682
- }
683
-
684
- export async function getAddOns(
685
- companyId: string,
686
- options?: { productOptionId?: string; preCheckout?: boolean }
687
- ): Promise<AddOn[]> {
688
- const params = new URLSearchParams({ companyId });
689
- if (options?.productOptionId) {
690
- const po = normalizeBookingProductId(options.productOptionId);
691
- if (po) params.set('productOptionId', po);
692
- }
693
- if (options?.preCheckout !== undefined) params.set('preCheckout', String(options.preCheckout));
694
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/add-ons?${params}`);
695
- if (!res.ok) {
696
- const err = await res.json();
697
- throw new Error(err.errorMessage || err.error || 'Failed to get add-ons');
698
- }
699
- const data = await res.json();
700
- return data.data?.addOns ?? [];
701
- }
702
-
703
- export interface UpdatePickupLocationPayload {
704
- /** Pickup location ID from product's pickup locations */
705
- pickupLocationId?: string | null;
706
- /** Custom address (e.g. hotel name) - for Private Shuttle when using "Use this address" */
707
- travelerHotel?: string | null;
708
- }
709
-
710
- export async function updatePickupLocation(
711
- bookingReference: string,
712
- lastName: string,
713
- payload: UpdatePickupLocationPayload | string
714
- ): Promise<unknown> {
715
- const body =
716
- typeof payload === 'string'
717
- ? { pickupLocationId: payload }
718
- : {
719
- pickupLocationId: payload.pickupLocationId ?? undefined,
720
- travelerHotel: payload.travelerHotel ?? undefined,
721
- };
722
- const res = await fetch(
723
- `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/pickup-location?lastName=${encodeURIComponent(lastName)}`,
724
- {
725
- method: 'PATCH',
726
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
727
- body: JSON.stringify(body),
728
- }
729
- );
730
- if (!res.ok) {
731
- const err = await res.json();
732
- throw new Error(err.errorMessage || err.error || 'Failed to update pickup location');
733
- }
734
- const data = await res.json();
735
- return data.data;
736
- }
737
-
738
- /** Preview itinerary with new pickup (no save). Returns updated itineraryDisplay or null if preview not supported. */
739
- export async function previewPickupLocationChange(
740
- bookingReference: string,
741
- lastName: string,
742
- payload: UpdatePickupLocationPayload
743
- ): Promise<{ itineraryDisplay?: Array<{ stepType?: string; time?: string; place?: string; label?: string }> } | null> {
744
- const body = {
745
- pickupLocationId: payload.pickupLocationId ?? undefined,
746
- travelerHotel: payload.travelerHotel ?? undefined,
747
- };
748
- const url = `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/pickup-location?lastName=${encodeURIComponent(lastName)}&preview=true`;
749
- const res = await fetch(url, {
750
- method: 'PATCH',
751
- headers: getAuthHeaders(),
752
- body: JSON.stringify(body),
753
- });
754
- if (!res.ok) return null;
755
- const data = await res.json();
756
- return data.data ?? null;
757
- }
758
-
759
- export type CommunicationMethod = 'EMAIL' | 'WHATSAPP' | 'SMS';
760
-
761
- export interface UpdateContactPayload {
762
- communicationPreference?: CommunicationMethod[];
763
- email?: string | null;
764
- phoneNumber?: string | null;
765
- whatsAppNumber?: string | null;
766
- }
767
-
768
- export async function updateContactDetails(
769
- bookingReference: string,
770
- lastName: string,
771
- payload: UpdateContactPayload
772
- ): Promise<unknown> {
773
- const res = await fetch(
774
- `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/contact?lastName=${encodeURIComponent(lastName)}`,
775
- {
776
- method: 'PATCH',
777
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
778
- body: JSON.stringify(payload),
779
- }
780
- );
781
- if (!res.ok) {
782
- const err = await res.json();
783
- throw new Error(err.errorMessage || err.error || 'Failed to update contact details');
784
- }
785
- const data = await res.json();
786
- return data.data;
787
- }
788
-
789
- export type ItineraryReviewGuestAction = 'approve' | 'request_changes';
790
-
791
- /** Guest approves proposed private shuttle itinerary or requests changes (manage-booking). */
792
- export async function respondToItineraryReview(
793
- bookingReference: string,
794
- lastName: string,
795
- action: ItineraryReviewGuestAction,
796
- comment?: string
797
- ): Promise<unknown> {
798
- const body: { action: string; comment?: string } = { action };
799
- if (action === 'request_changes') {
800
- body.comment = comment ?? '';
801
- }
802
- const res = await fetch(
803
- `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/itinerary-review?lastName=${encodeURIComponent(lastName)}`,
804
- {
805
- method: 'PATCH',
806
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
807
- body: JSON.stringify(body),
808
- }
809
- );
810
- if (!res.ok) {
811
- const err = await res.json().catch(() => ({}));
812
- throw new Error(
813
- (err as { errorMessage?: string; error?: string }).errorMessage ||
814
- (err as { error?: string }).error ||
815
- 'Failed to update itinerary'
816
- );
817
- }
818
- const data = await res.json();
819
- return data.data;
820
- }
821
-
822
- export interface CreatePaymentIntentResponse {
823
- clientSecret: string;
824
- amount: number;
825
- currency: string;
826
- }
827
-
828
- export interface ChangeBookingQuoteRequest {
829
- bookingReference: string;
830
- lastName: string;
831
- newProductId: string;
832
- newDateTime: string;
833
- /** Outbound availability id for the new selection (must match option + datetime server-side). */
834
- newAvailabilityId?: string | null;
835
- newPickupLocationId?: string | null;
836
- newReturnAvailabilityId?: string | null;
837
- newPassengerCounts?: Array<{ category: string; count: number }>;
838
- newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
839
- /** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
840
- clientProposedTotal?: number;
841
- }
842
-
843
- export interface ChangeBookingQuoteReceipt {
844
- subtotal?: number;
845
- tax?: number;
846
- total: number;
847
- currency?: string;
848
- lineItems?: Array<{
849
- label?: string;
850
- amount?: number;
851
- type?: string;
852
- quantity?: number;
853
- }>;
854
- }
855
-
856
- export interface ChangeBookingQuoteResponse {
857
- changeIntentId?: string;
858
- previousTotalCents?: number;
859
- newTotalCents?: number;
860
- amountDueCents?: number;
861
- expiresAt?: string;
862
- original?: {
863
- total?: number;
864
- currency?: string;
865
- lineItems?: Array<{ label?: string; amount?: number; type?: string; quantity?: number }>;
866
- };
867
- proposed?: {
868
- total?: number;
869
- currency?: string;
870
- lineItems?: Array<{ label?: string; amount?: number; type?: string; quantity?: number }>;
871
- };
872
- originalReceipt?: ChangeBookingQuoteReceipt;
873
- newReceipt?: ChangeBookingQuoteReceipt;
874
- priceDiff: number;
875
- currency?: string;
876
- canProceed?: boolean;
877
- reasonIfBlocked?: string;
878
- }
879
-
880
- export interface CreateChangePaymentIntentResponse {
881
- paymentIntentId: string;
882
- clientSecret: string;
883
- amountDueCents: number;
884
- currency: string;
885
- }
886
-
887
- export interface ConfirmFreeChangeResponse {
888
- status?: string;
889
- booking?: unknown;
890
- }
891
-
892
- export interface ApplyPaidChangeResponse {
893
- status: 'APPLIED' | 'PROCESSING' | 'FAILED';
894
- booking?: unknown;
895
- failureCode?: string;
896
- failureMessage?: string;
897
- }
898
-
899
- export interface BookingChangeIntentStatusResponse {
900
- status: 'CREATED' | 'PAYMENT_REQUIRED' | 'PAYMENT_SUCCEEDED' | 'APPLYING' | 'APPLIED' | 'FAILED' | 'EXPIRED';
901
- bookingReference?: string;
902
- booking?: unknown;
903
- failureCode?: string;
904
- failureMessage?: string;
905
- }
906
-
907
- export async function createManagePaymentIntent(
908
- bookingReference: string,
909
- lastName: string,
910
- paymentType: 'deposit' | 'full'
911
- ): Promise<CreatePaymentIntentResponse> {
912
- const res = await fetch(`${API_BASE}/checkout/manage-payment-intent`, {
913
- method: 'POST',
914
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
915
- body: JSON.stringify({ bookingReference, lastName, paymentType }),
916
- });
917
- if (!res.ok) {
918
- const err = await res.json();
919
- throw new Error(err.errorMessage || err.error || 'Failed to create payment');
920
- }
921
- const data = await res.json();
922
- return (data.data ?? data) as CreatePaymentIntentResponse;
923
- }
924
-
925
- /** Cancel a booking (public manage-booking page). Requires lastName for verification. */
926
- export async function cancelBookingPublic(
927
- bookingReference: string,
928
- lastName: string
929
- ): Promise<{ bookingReference: string; status: string }> {
930
- const res = await fetch(
931
- `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/cancel?lastName=${encodeURIComponent(lastName)}`,
932
- {
933
- method: 'POST',
934
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
935
- }
936
- );
937
- if (!res.ok) {
938
- if (res.status === 404) throw new Error('Booking not found or last name does not match');
939
- const err = await res.json();
940
- throw new Error(err.errorMessage || err.error || 'Failed to cancel booking');
941
- }
942
- const data = await res.json();
943
- return data.data ?? { bookingReference, status: 'CANCELLED' };
944
- }
945
-
946
- export async function createBalancePaymentIntent(
947
- bookingReference: string,
948
- lastName: string
949
- ): Promise<CreatePaymentIntentResponse> {
950
- const res = await fetch(`${API_BASE}/checkout/balance-payment-intent`, {
951
- method: 'POST',
952
- headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
953
- body: JSON.stringify({ bookingReference, lastName }),
954
- });
955
- if (!res.ok) {
956
- const err = await res.json();
957
- throw new Error(err.errorMessage || err.error || 'Failed to create payment');
958
- }
959
- const data = await res.json();
960
- return (data.data ?? data) as CreatePaymentIntentResponse;
961
- }
962
-
963
- export async function quoteChangeBooking(
964
- request: ChangeBookingQuoteRequest
965
- ): Promise<ChangeBookingQuoteResponse> {
966
- const { bookingReference, lastName, ...payload } = request;
967
- const res = await fetch(
968
- `${API_BASE}/1/public/bookings/${encodeURIComponent(bookingReference)}/change/quote?lastName=${encodeURIComponent(lastName)}`,
969
- {
970
- method: 'POST',
971
- headers: getAuthHeaders(),
972
- body: JSON.stringify(payload),
973
- }
974
- );
975
- if (!res.ok) {
976
- const err = await parseJsonSafely(res);
977
- const message = isApiErrorPayload(err)
978
- ? err.errorMessage || err.error || 'Failed to quote booking change'
979
- : 'Failed to quote booking change';
980
- throw new Error(message);
981
- }
982
- const data = await parseJsonSafely(res);
983
- return ((data as { data?: ChangeBookingQuoteResponse } | null)?.data ??
984
- data) as ChangeBookingQuoteResponse;
985
- }
986
-
987
- export async function createChangeBookingPaymentIntent(
988
- changeIntentId: string
989
- ): Promise<CreateChangePaymentIntentResponse> {
990
- const res = await fetch(
991
- `${API_BASE}/1/public/booking-change-intents/${encodeURIComponent(changeIntentId)}/payment-intent`,
992
- {
993
- method: 'POST',
994
- headers: getAuthHeaders(),
995
- }
996
- );
997
- if (!res.ok) {
998
- const err = await parseJsonSafely(res);
999
- const message = isApiErrorPayload(err)
1000
- ? err.errorMessage || err.error || 'Failed to create payment intent for booking change'
1001
- : 'Failed to create payment intent for booking change';
1002
- throw new Error(message);
1003
- }
1004
- const data = await parseJsonSafely(res);
1005
- return ((data as { data?: CreateChangePaymentIntentResponse } | null)?.data ??
1006
- data) as CreateChangePaymentIntentResponse;
1007
- }
1008
-
1009
- export async function confirmFreeChangeBooking(
1010
- changeIntentId: string
1011
- ): Promise<ConfirmFreeChangeResponse> {
1012
- const res = await fetch(
1013
- `${API_BASE}/1/public/booking-change-intents/${encodeURIComponent(changeIntentId)}/confirm-free`,
1014
- {
1015
- method: 'POST',
1016
- headers: getAuthHeaders(),
1017
- }
1018
- );
1019
- if (!res.ok) {
1020
- const err = await parseJsonSafely(res);
1021
- const message = isApiErrorPayload(err)
1022
- ? err.errorMessage || err.error || 'Failed to confirm free booking change'
1023
- : 'Failed to confirm free booking change';
1024
- throw new Error(message);
1025
- }
1026
- const data = await parseJsonSafely(res);
1027
- return ((data as { data?: ConfirmFreeChangeResponse } | null)?.data ??
1028
- data) as ConfirmFreeChangeResponse;
1029
- }
1030
-
1031
- export async function applyPaidChangeBooking(
1032
- changeIntentId: string,
1033
- paymentIntentId: string
1034
- ): Promise<ApplyPaidChangeResponse> {
1035
- const res = await fetch(
1036
- `${API_BASE}/1/public/booking-change-intents/${encodeURIComponent(changeIntentId)}/apply`,
1037
- {
1038
- method: 'POST',
1039
- headers: getAuthHeaders(),
1040
- body: JSON.stringify({ paymentIntentId }),
1041
- }
1042
- );
1043
- if (!res.ok) {
1044
- const err = await parseJsonSafely(res);
1045
- const message = isApiErrorPayload(err)
1046
- ? err.errorMessage || err.error || 'Failed to apply paid booking change'
1047
- : 'Failed to apply paid booking change';
1048
- throw new Error(message);
1049
- }
1050
- const data = await parseJsonSafely(res);
1051
- return ((data as { data?: ApplyPaidChangeResponse } | null)?.data ??
1052
- data) as ApplyPaidChangeResponse;
1053
- }
1054
-
1055
- export async function getBookingChangeIntentStatus(
1056
- changeIntentId: string
1057
- ): Promise<BookingChangeIntentStatusResponse> {
1058
- const res = await fetchBookingGetWithRetry(
1059
- `${API_BASE}/1/public/booking-change-intents/${encodeURIComponent(changeIntentId)}`,
1060
- { cache: 'no-store' },
1061
- );
1062
- if (!res.ok) {
1063
- const err = await parseJsonSafely(res);
1064
- const message = isApiErrorPayload(err)
1065
- ? err.errorMessage || err.error || 'Failed to get booking change status'
1066
- : 'Failed to get booking change status';
1067
- throw new Error(message);
1068
- }
1069
- const data = await parseJsonSafely(res);
1070
- return ((data as { data?: BookingChangeIntentStatusResponse } | null)?.data ??
1071
- data) as BookingChangeIntentStatusResponse;
1072
- }
1073
-
1074
- // ============ Booking Flow (availability, reserve, checkout) ============
1075
-
1076
- /** Applied pricing adjustment (dynamic rule or deal) for price breakdown display */
1077
- export interface AppliedAdjustment {
1078
- type: string;
1079
- id: string;
1080
- name: string;
1081
- changeByCurrency?: Record<string, number>;
1082
- adjustmentType?: string;
1083
- adjustmentValue?: number;
1084
- }
1085
-
1086
- export interface AvailabilityRate {
1087
- rateId: string;
1088
- category: string;
1089
- available: number;
1090
- price?: number;
1091
- priceByCurrency?: Record<string, number>;
1092
- appliedAdjustments?: AppliedAdjustment[];
1093
- applied_adjustments?: AppliedAdjustment[];
1094
- }
1095
-
1096
- export interface ReturnOption {
1097
- returnAvailabilityId: string;
1098
- dateTime: string;
1099
- vacancies: number;
1100
- totalCapacity: number;
1101
- bookedCapacity?: number;
1102
- pricesByCategory?: { retailPrices: Array<{ category: string; price: number }> };
1103
- priceAdjustmentByCurrency?: Record<string, number>;
1104
- returnLocation: string;
1105
- mostPopular?: boolean;
1106
- }
1107
-
1108
- export interface Availability {
1109
- dateTime: string;
1110
- productId?: string;
1111
- vacancies: number;
1112
- totalCapacity?: number;
1113
- bookedCapacity?: number;
1114
- resourceCount?: number;
1115
- currency: string;
1116
- availabilityId?: string;
1117
- productOptionId?: string;
1118
- productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
1119
- suggestedStartTimes?: string[];
1120
- rates?: AvailabilityRate[];
1121
- returnOptions?: ReturnOption[];
1122
- pricesByCategory?: {
1123
- retailPrices: Array<{ category: string; price: number }>;
1124
- };
1125
- }
1126
-
1127
- export type PrecomputedPricesByCategory = Record<string, Record<string, number>>;
1128
-
1129
- export interface GetAvailabilitiesResponse {
1130
- availabilities: Availability[];
1131
- pricingConfig?: PricingConfig;
1132
- precomputedPrices?: PrecomputedPricesByCategory;
1133
- resourcePriceByCurrency?: Record<string, number>;
1134
- resourcePriceByOption?: Record<string, Record<string, number>>;
1135
- }
1136
-
1137
- export interface GetAvailabilitiesOptions {
1138
- promoCode?: string | null;
1139
- allOptions?: boolean;
1140
- /**
1141
- * When set, TicketBooth should return rates/precomputed prices for this partner pricing profile.
1142
- * Must match the profile linked on the partner (e.g. capabilities.pricingProfileId).
1143
- */
1144
- pricingProfileId?: string | null;
1145
- /**
1146
- * When set, TicketBooth returns only cancellation policies allowed by this partner profile.
1147
- */
1148
- cancellationPolicyProfileId?: string | null;
1149
- }
1150
-
1151
- export async function getAvailabilities(
1152
- productIdOrOptionId: string,
1153
- startDate: string,
1154
- endDate: string,
1155
- options?: GetAvailabilitiesOptions
1156
- ): Promise<GetAvailabilitiesResponse> {
1157
- const normalizedLookupId = normalizeAvailabilityLookupId(productIdOrOptionId);
1158
- if (!normalizedLookupId) {
1159
- const endpoint = '/1/get-availabilities';
1160
- const debugMessage = 'Missing productId/productOptionId for availabilities request';
1161
- if (typeof window !== 'undefined') {
1162
- console.warn('[booking-api] Skipping availabilities request due to invalid lookup ID', {
1163
- original: productIdOrOptionId,
1164
- });
1165
- }
1166
- reportClientFetchError({
1167
- endpoint,
1168
- errorClass: 'APP_ERROR_200',
1169
- message: debugMessage,
1170
- });
1171
- return { availabilities: [] };
1172
- }
1173
- if (normalizedLookupId !== productIdOrOptionId.trim() && typeof window !== 'undefined') {
1174
- console.warn('[booking-api] Sanitized malformed availability lookup ID', {
1175
- original: productIdOrOptionId,
1176
- sanitized: normalizedLookupId,
1177
- });
1178
- }
1179
- const params = new URLSearchParams({
1180
- productId: normalizedLookupId,
1181
- startDate,
1182
- endDate,
1183
- });
1184
- if (options?.promoCode?.trim()) params.set('promoCode', options.promoCode.trim());
1185
- if (options?.allOptions === true) params.set('allOptions', 'true');
1186
- const pricingProfileId = options?.pricingProfileId?.trim();
1187
- if (pricingProfileId) params.set('pricingProfileId', pricingProfileId);
1188
- const cancellationPolicyProfileId = options?.cancellationPolicyProfileId?.trim();
1189
- if (cancellationPolicyProfileId) {
1190
- params.set('cancellationPolicyProfileId', cancellationPolicyProfileId);
1191
- }
1192
- const endpoint = '/1/get-availabilities';
1193
- const url = `${API_BASE}${endpoint}?${params}`;
1194
- let res: Response;
1195
- try {
1196
- res = await fetchBookingGetWithRetry(url);
1197
- } catch (err) {
1198
- logBookingApiNetworkError(endpoint, err);
1199
- const debugMessage = err instanceof Error ? err.message : String(err);
1200
- reportClientFetchError({
1201
- endpoint,
1202
- errorClass: 'NETWORK',
1203
- message: debugMessage,
1204
- });
1205
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1206
- }
1207
-
1208
- if (!res.ok) {
1209
- const errPayload = await parseJsonSafely(res);
1210
- const debugMessage = toHttpErrorMessage(endpoint, res.status, errPayload, 'Failed to get availabilities');
1211
- reportClientFetchError({
1212
- endpoint,
1213
- errorClass: 'HTTP',
1214
- message: debugMessage,
1215
- httpStatus: res.status,
1216
- errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
1217
- });
1218
- throw createUserError(endpoint, 'HTTP', debugMessage);
1219
- }
1220
-
1221
- const data = await parseJsonSafely(res);
1222
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to get availabilities');
1223
- if (appError) {
1224
- reportClientFetchError({
1225
- endpoint,
1226
- errorClass: 'APP_ERROR_200',
1227
- message: appError,
1228
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1229
- });
1230
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
1231
- }
1232
-
1233
- const bookingData = (data as { data?: GetAvailabilitiesResponse } | null)?.data;
1234
- return {
1235
- availabilities: bookingData?.availabilities ?? [],
1236
- pricingConfig: bookingData?.pricingConfig,
1237
- precomputedPrices: bookingData?.precomputedPrices,
1238
- resourcePriceByCurrency: bookingData?.resourcePriceByCurrency,
1239
- resourcePriceByOption: bookingData?.resourcePriceByOption,
1240
- };
1241
- }
1242
-
1243
- export interface BookingItem {
1244
- category: string;
1245
- count: number;
1246
- }
1247
-
1248
- export interface ReserveRequest {
1249
- productId: string;
1250
- dateTime: string;
1251
- /** Exact outbound inventory row from get-availabilities; ensures reserve matches the slot the user selected. */
1252
- availabilityId?: string;
1253
- bookingItems: BookingItem[];
1254
- pickupLocationId?: string;
1255
- returnAvailabilityId?: string;
1256
- currency?: string;
1257
- travelerHotel?: string;
1258
- promoCode?: string;
1259
- cancellationPolicyId?: string;
1260
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
1261
- /** Private Shuttle only: user-selected start time (ISO 8601) */
1262
- startTime?: string;
1263
- /** Private Shuttle only: total passenger count (may be capped when capacity limited) */
1264
- passengerCount?: number;
1265
- /** Private Shuttle only: what customer actually requested (use when capacity limited; for display in emails/manage) */
1266
- requestedPassengerCount?: number;
1267
- /** Private Shuttle only: draft destinations and planning notes */
1268
- draftItinerary?: { destinations: string[]; planningNotes?: string };
1269
- /** Private Shuttle only: child safety seats count */
1270
- childSafetySeatsCount?: number;
1271
- /** Private Shuttle only: dietary restrictions / food allergies */
1272
- foodRestrictions?: string;
1273
- /** Admin only: additional hours add-on (extends duration) */
1274
- additionalHoursCount?: number;
1275
- /**
1276
- * Admin only: allow outbound/return capacity holds above vacancies. Server requires admin JWT
1277
- * and still validates permission; omit or false for public/partner flows.
1278
- */
1279
- allowOverbook?: boolean;
1280
- /** Optional; merged with server tracking (authenticated reserve). */
1281
- source?: string;
1282
- sourceMetadata?: BookingSourceMetadata;
1283
- source_metadata?: BookingSourceMetadata;
1284
- }
1285
-
1286
- /** Safe subset of reserve payload for telemetry (no free-text traveler fields). */
1287
- export function summarizeReserveRequestForTelemetry(req: ReserveRequest): Record<string, unknown> {
1288
- const ticketUnits = req.bookingItems.reduce(
1289
- (sum, b) => sum + Math.max(0, Number(b.count) || 0),
1290
- 0
1291
- );
1292
- return {
1293
- productId: req.productId,
1294
- dateTime: req.dateTime,
1295
- availabilityId: req.availabilityId ?? null,
1296
- returnAvailabilityId: req.returnAvailabilityId ?? null,
1297
- pickupLocationId: req.pickupLocationId ?? null,
1298
- bookingItems: req.bookingItems.map((b) => ({ category: b.category, count: b.count })),
1299
- totalTicketUnits: ticketUnits,
1300
- passengerCount: req.passengerCount ?? null,
1301
- requestedPassengerCount: req.requestedPassengerCount ?? null,
1302
- startTime: req.startTime ?? null,
1303
- currency: req.currency ?? null,
1304
- cancellationPolicyId: req.cancellationPolicyId ?? null,
1305
- hasAddOns: Array.isArray(req.addOnSelections) && req.addOnSelections.length > 0,
1306
- addOnSelectionsCount: req.addOnSelections?.length ?? 0,
1307
- promoPresent: Boolean(req.promoCode?.trim()),
1308
- additionalHoursCount: req.additionalHoursCount ?? null,
1309
- childSafetySeatsCount: req.childSafetySeatsCount ?? null,
1310
- allowOverbook: req.allowOverbook === true,
1311
- };
1312
- }
1313
-
1314
- /**
1315
- * `/1/reserve` success payload (`ReserveResponseData` on the API).
1316
- * Hold expiry is serialized as `reservationExpiration` (not `expiresAt`).
1317
- */
1318
- export interface ReserveResponse {
1319
- reservationReference: string;
1320
- /** ISO 8601 instant when the hold expires (API field name). */
1321
- reservationExpiration: string;
1322
- /**
1323
- * Same as `reservationExpiration`; populated in `createReservation()` for callers that still read `expiresAt`.
1324
- */
1325
- expiresAt: string;
1326
- totalAmount?: number;
1327
- currency?: string;
1328
- }
1329
-
1330
- export async function createReservation(request: ReserveRequest): Promise<ReserveResponse> {
1331
- const endpoint = '/1/reserve';
1332
- const reservePayload = withExplicitBookingSource(request);
1333
- let res: Response;
1334
- try {
1335
- res = await fetch(`${API_BASE}${endpoint}`, {
1336
- method: 'POST',
1337
- headers: getAuthHeaders(),
1338
- body: JSON.stringify({ data: reservePayload }),
1339
- });
1340
- } catch (err) {
1341
- const debugMessage = err instanceof Error ? err.message : String(err);
1342
- reportClientFetchError({
1343
- endpoint,
1344
- errorClass: 'NETWORK',
1345
- message: debugMessage,
1346
- metadata: {
1347
- failureKind: 'RESERVE_NETWORK',
1348
- reserveRequest: summarizeReserveRequestForTelemetry(request),
1349
- },
1350
- });
1351
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1352
- }
1353
- const text = await res.text();
1354
- if (!res.ok) {
1355
- let err: { errorMessage?: string; error?: string; errorCode?: string };
1356
- try {
1357
- err = JSON.parse(text);
1358
- } catch {
1359
- err = { errorMessage: text || 'Failed to create reservation' };
1360
- }
1361
- const debugMessage = err.errorMessage || err.error || 'Failed to create reservation';
1362
- const insufficientCapacity = isInsufficientCapacityApiError(err.errorCode, debugMessage);
1363
- reportClientFetchError({
1364
- endpoint,
1365
- errorClass: 'HTTP',
1366
- message: insufficientCapacity
1367
- ? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
1368
- : debugMessage,
1369
- httpStatus: res.status,
1370
- errorCode: err.errorCode,
1371
- metadata: {
1372
- failureKind: insufficientCapacity
1373
- ? 'RESERVE_INSUFFICIENT_CAPACITY'
1374
- : 'RESERVE_HTTP_ERROR',
1375
- reserveRequest: summarizeReserveRequestForTelemetry(request),
1376
- apiErrorMessage: debugMessage,
1377
- apiErrorCode: err.errorCode ?? null,
1378
- },
1379
- });
1380
- throw createUserError(endpoint, 'HTTP', debugMessage, err.errorCode);
1381
- }
1382
- let data: { data?: ReserveResponse; errorCode?: string; errorMessage?: string };
1383
- try {
1384
- data = JSON.parse(text);
1385
- } catch {
1386
- throw new Error('Invalid response from server');
1387
- }
1388
- if (data.errorCode || data.errorMessage) {
1389
- const debugMessage = data.errorMessage || 'Failed to create reservation';
1390
- const insufficientCapacity = isInsufficientCapacityApiError(data.errorCode, debugMessage);
1391
- reportClientFetchError({
1392
- endpoint,
1393
- errorClass: 'APP_ERROR_200',
1394
- message: insufficientCapacity
1395
- ? `[RESERVE_INSUFFICIENT_CAPACITY] ${debugMessage}`
1396
- : debugMessage,
1397
- errorCode: data.errorCode,
1398
- metadata: {
1399
- failureKind: insufficientCapacity
1400
- ? 'RESERVE_INSUFFICIENT_CAPACITY'
1401
- : 'RESERVE_APP_ERROR_200',
1402
- reserveRequest: summarizeReserveRequestForTelemetry(request),
1403
- apiErrorMessage: debugMessage,
1404
- apiErrorCode: data.errorCode ?? null,
1405
- },
1406
- });
1407
- throw createUserError(endpoint, 'APP_ERROR_200', debugMessage, data.errorCode);
1408
- }
1409
- if (!data.data?.reservationReference) {
1410
- throw new Error('Invalid response: missing reservationReference');
1411
- }
1412
- const raw = data.data as {
1413
- reservationReference: string;
1414
- reservationExpiration?: string;
1415
- expiresAt?: string;
1416
- totalAmount?: number;
1417
- currency?: string;
1418
- };
1419
- const expiration = raw.reservationExpiration ?? raw.expiresAt;
1420
- if (!expiration) {
1421
- throw new Error('Invalid response: missing reservation hold expiration');
1422
- }
1423
- return {
1424
- reservationReference: raw.reservationReference,
1425
- reservationExpiration: expiration,
1426
- expiresAt: expiration,
1427
- totalAmount: raw.totalAmount,
1428
- currency: raw.currency,
1429
- };
1430
- }
1431
-
1432
- export async function cancelReservation(reservationReference: string): Promise<void> {
1433
- const res = await fetch(`${API_BASE}/1/cancel-reservation`, {
1434
- method: 'POST',
1435
- headers: getAuthHeaders(),
1436
- body: JSON.stringify({ data: { reservationReference, gygBookingReference: '' } }),
1437
- });
1438
- if (!res.ok) {
1439
- const err = await res.json();
1440
- throw new Error(err.errorMessage || err.error || 'Failed to cancel reservation');
1441
- }
1442
- }
1443
-
1444
- /**
1445
- * Best-effort reservation cancellation for unload/pagehide flows.
1446
- * Uses keepalive and never throws.
1447
- */
1448
- export function cancelReservationBestEffort(reservationReference: string): void {
1449
- try {
1450
- void fetch(`${API_BASE}/1/cancel-reservation`, {
1451
- method: 'POST',
1452
- headers: getAuthHeaders(),
1453
- body: JSON.stringify({ data: { reservationReference, gygBookingReference: '' } }),
1454
- keepalive: true,
1455
- }).catch(() => {
1456
- // best-effort only
1457
- });
1458
- } catch {
1459
- // best-effort only
1460
- }
1461
- }
1462
-
1463
- export interface CheckoutReceiptLine {
1464
- label: string;
1465
- amount: number;
1466
- type?: string;
1467
- quantity?: number;
1468
- }
1469
-
1470
- export interface CheckoutBreakdown {
1471
- lineItems: CheckoutReceiptLine[];
1472
- totalAmount: number;
1473
- currency: string;
1474
- }
1475
-
1476
- export interface CreatePaymentIntentRequest {
1477
- productId: string;
1478
- optionId: string;
1479
- date: string;
1480
- time: string;
1481
- quantity: number;
1482
- customerEmail?: string;
1483
- customerFirstName?: string;
1484
- customerLastName?: string;
1485
- currency?: string;
1486
- reservationReference?: string;
1487
- travelerHotel?: string;
1488
- pickupLocationId?: string;
1489
- returnAvailabilityId?: string;
1490
- promoCode?: string;
1491
- cancellationPolicyId?: string;
1492
- termsAcceptedAt?: string;
1493
- checkoutBreakdown?: CheckoutBreakdown;
1494
- itineraryDisplay?: ItineraryDisplayStep[];
1495
- skipConfirmationCommunications?: boolean;
1496
- disableAutoCommunications?: boolean;
1497
- /** Private Shuttle deposit: pay deposit now, balance charged before trip */
1498
- paymentPlanType?: 'DEPOSIT';
1499
- depositAmount?: number;
1500
- balanceAmount?: number;
1501
- totalAmount?: number;
1502
- balanceChargeDaysBefore?: number;
1503
- source?: string;
1504
- sourceMetadata?: BookingSourceMetadata;
1505
- source_metadata?: BookingSourceMetadata;
1506
- }
1507
-
1508
- export interface CreatePaymentIntentResponseFull {
1509
- clientSecret?: string;
1510
- freeBooking?: boolean;
1511
- totalAmount?: number;
1512
- currency?: string;
1513
- }
1514
-
1515
- export async function createBookingPaymentIntent(
1516
- request: CreatePaymentIntentRequest
1517
- ): Promise<CreatePaymentIntentResponseFull> {
1518
- const endpoint = '/checkout/payment-intent';
1519
- const payload = withExplicitBookingSource(request);
1520
- logBookingSourceDebug('request', '/checkout/payment-intent', {
1521
- reservationReference: payload.reservationReference,
1522
- source: payload.source,
1523
- sourceMetadata: payload.sourceMetadata,
1524
- source_metadata: payload.source_metadata,
1525
- });
1526
- let res: Response;
1527
- try {
1528
- res = await fetch(`${API_BASE}${endpoint}`, {
1529
- method: 'POST',
1530
- headers: getAuthHeaders(),
1531
- body: JSON.stringify(payload),
1532
- });
1533
- } catch (err) {
1534
- const debugMessage = err instanceof Error ? err.message : String(err);
1535
- reportClientFetchError({
1536
- endpoint,
1537
- errorClass: 'NETWORK',
1538
- message: debugMessage,
1539
- });
1540
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1541
- }
1542
- if (!res.ok) {
1543
- const err = await parseJsonSafely(res);
1544
- logBookingSourceDebug('error', '/checkout/payment-intent', err);
1545
- const debugMessage = toHttpErrorMessage(
1546
- endpoint,
1547
- res.status,
1548
- err,
1549
- 'Failed to create payment intent'
1550
- );
1551
- reportClientFetchError({
1552
- endpoint,
1553
- errorClass: 'HTTP',
1554
- message: debugMessage,
1555
- httpStatus: res.status,
1556
- errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
1557
- });
1558
- throw createUserError(endpoint, 'HTTP', debugMessage);
1559
- }
1560
- const data = await parseJsonSafely(res);
1561
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to create payment intent');
1562
- if (appError) {
1563
- reportClientFetchError({
1564
- endpoint,
1565
- errorClass: 'APP_ERROR_200',
1566
- message: appError,
1567
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1568
- });
1569
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
1570
- }
1571
- logBookingSourceDebug('response', '/checkout/payment-intent', data);
1572
- return (data as { data?: CreatePaymentIntentResponseFull } | null)?.data ?? (data as CreatePaymentIntentResponseFull);
1573
- }
1574
-
1575
- /** Alias for createBookingPaymentIntent (used by ticketbooth BookingFlow) */
1576
- export const createPaymentIntent = createBookingPaymentIntent;
1577
-
1578
- export interface ConfirmFreeBookingRequest {
1579
- reservationReference: string;
1580
- productId: string;
1581
- optionId: string;
1582
- date: string;
1583
- time: string;
1584
- customerEmail?: string;
1585
- customerFirstName?: string;
1586
- customerLastName?: string;
1587
- currency?: string;
1588
- travelerHotel?: string;
1589
- pickupLocationId?: string;
1590
- termsAcceptedAt?: string;
1591
- itineraryDisplay?: ItineraryDisplayStep[];
1592
- skipConfirmationCommunications?: boolean;
1593
- disableAutoCommunications?: boolean;
1594
- source?: string;
1595
- sourceMetadata?: BookingSourceMetadata;
1596
- source_metadata?: BookingSourceMetadata;
1597
- }
1598
-
1599
- export interface ConfirmFreeBookingResponse {
1600
- bookingReference: string;
1601
- reservationReference: string;
1602
- }
1603
-
1604
- export async function confirmFreeBooking(
1605
- request: ConfirmFreeBookingRequest
1606
- ): Promise<ConfirmFreeBookingResponse> {
1607
- const endpoint = '/checkout/confirm-free-booking';
1608
- const payload = withExplicitBookingSource(request);
1609
- logBookingSourceDebug('request', '/checkout/confirm-free-booking', {
1610
- reservationReference: payload.reservationReference,
1611
- source: payload.source,
1612
- sourceMetadata: payload.sourceMetadata,
1613
- source_metadata: payload.source_metadata,
1614
- });
1615
- let res: Response;
1616
- try {
1617
- res = await fetch(`${API_BASE}${endpoint}`, {
1618
- method: 'POST',
1619
- headers: getAuthHeaders(),
1620
- body: JSON.stringify(payload),
1621
- });
1622
- } catch (err) {
1623
- const debugMessage = err instanceof Error ? err.message : String(err);
1624
- reportClientFetchError({ endpoint, errorClass: 'NETWORK', message: debugMessage });
1625
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1626
- }
1627
- if (!res.ok) {
1628
- const err = await parseJsonSafely(res);
1629
- logBookingSourceDebug('error', '/checkout/confirm-free-booking', err);
1630
- const debugMessage = toHttpErrorMessage(endpoint, res.status, err, 'Failed to confirm free booking');
1631
- reportClientFetchError({
1632
- endpoint,
1633
- errorClass: 'HTTP',
1634
- message: debugMessage,
1635
- httpStatus: res.status,
1636
- errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
1637
- });
1638
- throw createUserError(endpoint, 'HTTP', debugMessage);
1639
- }
1640
- const data = await parseJsonSafely(res);
1641
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm free booking');
1642
- if (appError) {
1643
- reportClientFetchError({
1644
- endpoint,
1645
- errorClass: 'APP_ERROR_200',
1646
- message: appError,
1647
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1648
- });
1649
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
1650
- }
1651
- logBookingSourceDebug('response', '/checkout/confirm-free-booking', data);
1652
- return ((data as { data?: ConfirmFreeBookingResponse } | null)?.data ??
1653
- data) as ConfirmFreeBookingResponse;
1654
- }
1655
-
1656
- export interface ConfirmBookingWithoutPaymentRequest {
1657
- reservationReference: string;
1658
- productId: string;
1659
- optionId: string;
1660
- date: string;
1661
- time: string;
1662
- customerEmail?: string;
1663
- customerFirstName?: string;
1664
- customerLastName?: string;
1665
- currency?: string;
1666
- travelerHotel?: string;
1667
- pickupLocationId?: string;
1668
- itineraryDisplay?: ItineraryDisplayStep[];
1669
- termsAcceptedAt?: string;
1670
- skipConfirmationCommunications?: boolean;
1671
- disableAutoCommunications?: boolean;
1672
- checkoutBreakdown: CheckoutBreakdown;
1673
- depositAmount: number;
1674
- balanceAmount: number;
1675
- totalAmount: number;
1676
- balanceChargeDaysBefore?: number;
1677
- source?: string;
1678
- sourceMetadata?: BookingSourceMetadata;
1679
- source_metadata?: BookingSourceMetadata;
1680
- }
1681
-
1682
- export interface ConfirmBookingWithoutPaymentResponse {
1683
- bookingReference: string;
1684
- reservationReference: string;
1685
- }
1686
-
1687
- export async function confirmBookingWithoutPayment(
1688
- request: ConfirmBookingWithoutPaymentRequest
1689
- ): Promise<ConfirmBookingWithoutPaymentResponse> {
1690
- const endpoint = '/checkout/confirm-booking-without-payment';
1691
- const payload = withExplicitBookingSource(request);
1692
- logBookingSourceDebug('request', '/checkout/confirm-booking-without-payment', {
1693
- reservationReference: payload.reservationReference,
1694
- source: payload.source,
1695
- sourceMetadata: payload.sourceMetadata,
1696
- source_metadata: payload.source_metadata,
1697
- });
1698
- let res: Response;
1699
- try {
1700
- res = await fetch(`${API_BASE}${endpoint}`, {
1701
- method: 'POST',
1702
- headers: getAuthHeaders(),
1703
- body: JSON.stringify(payload),
1704
- });
1705
- } catch (err) {
1706
- const debugMessage = err instanceof Error ? err.message : String(err);
1707
- reportClientFetchError({ endpoint, errorClass: 'NETWORK', message: debugMessage });
1708
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1709
- }
1710
- if (!res.ok) {
1711
- const err = await parseJsonSafely(res);
1712
- logBookingSourceDebug('error', '/checkout/confirm-booking-without-payment', err);
1713
- const debugMessage = toHttpErrorMessage(endpoint, res.status, err, 'Failed to confirm booking');
1714
- reportClientFetchError({
1715
- endpoint,
1716
- errorClass: 'HTTP',
1717
- message: debugMessage,
1718
- httpStatus: res.status,
1719
- errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
1720
- });
1721
- throw createUserError(endpoint, 'HTTP', debugMessage);
1722
- }
1723
- const data = await parseJsonSafely(res);
1724
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
1725
- if (appError) {
1726
- reportClientFetchError({
1727
- endpoint,
1728
- errorClass: 'APP_ERROR_200',
1729
- message: appError,
1730
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1731
- });
1732
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
1733
- }
1734
- logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
1735
- return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
1736
- data) as ConfirmBookingWithoutPaymentResponse;
1737
- }
1738
-
1739
- /** Partner portal DEFERRED_INVOICE: same body as [confirmBookingWithoutPayment]; uses Bearer partner JWT. */
1740
- export async function confirmPartnerBookingWithoutPayment(
1741
- request: ConfirmBookingWithoutPaymentRequest,
1742
- ): Promise<ConfirmBookingWithoutPaymentResponse> {
1743
- const endpoint = '/1/partner/confirm-booking-without-payment';
1744
- const payload = withExplicitBookingSource(request);
1745
- logBookingSourceDebug('request', endpoint, {
1746
- reservationReference: payload.reservationReference,
1747
- source: payload.source,
1748
- sourceMetadata: payload.sourceMetadata,
1749
- source_metadata: payload.source_metadata,
1750
- });
1751
- let res: Response;
1752
- try {
1753
- res = await fetch(`${API_BASE}${endpoint}`, {
1754
- method: 'POST',
1755
- headers: {
1756
- ...getAuthHeaders(),
1757
- 'Content-Type': 'application/json',
1758
- },
1759
- body: JSON.stringify(payload),
1760
- });
1761
- } catch (err) {
1762
- const debugMessage = err instanceof Error ? err.message : String(err);
1763
- reportClientFetchError({ endpoint, errorClass: 'NETWORK', message: debugMessage });
1764
- throw createUserError(endpoint, 'NETWORK', debugMessage);
1765
- }
1766
- if (!res.ok) {
1767
- const err = await parseJsonSafely(res);
1768
- logBookingSourceDebug('error', endpoint, err);
1769
- const debugMessage = toHttpErrorMessage(endpoint, res.status, err, 'Failed to confirm booking');
1770
- reportClientFetchError({
1771
- endpoint,
1772
- errorClass: 'HTTP',
1773
- message: debugMessage,
1774
- httpStatus: res.status,
1775
- errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
1776
- });
1777
- throw createUserError(endpoint, 'HTTP', debugMessage);
1778
- }
1779
- const data = await parseJsonSafely(res);
1780
- const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
1781
- if (appError) {
1782
- reportClientFetchError({
1783
- endpoint,
1784
- errorClass: 'APP_ERROR_200',
1785
- message: appError,
1786
- errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1787
- });
1788
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
1789
- }
1790
- logBookingSourceDebug('response', endpoint, data);
1791
- return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
1792
- data) as ConfirmBookingWithoutPaymentResponse;
1793
- }