@ticketboothapp/booking 1.2.99 → 1.2.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/components/booking/BookingDialog.tsx +29 -16
- package/src/components/booking/ChangeBookingDialog.tsx +10 -2
- package/src/components/booking/CheckoutModal.tsx +4 -1
- package/src/components/booking/NewBookingFlow.tsx +494 -134
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +208 -6
- package/src/components/booking/booking-flow-types.ts +50 -2
- package/src/components/booking/booking-flow-ui.ts +2 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +2 -2
- package/src/lib/booking/pricing.ts +1 -0
- package/src/lib/booking-api.ts +205 -2
- package/src/runtime/types.ts +2 -0
package/src/lib/booking-api.ts
CHANGED
|
@@ -355,26 +355,186 @@ function getAuthHeaders(): Record<string, string> {
|
|
|
355
355
|
|
|
356
356
|
/** Idempotent GET retries: transient network errors and 502/503/504 (new trace span each attempt). */
|
|
357
357
|
const BOOKING_GET_MAX_RETRIES = 2;
|
|
358
|
+
const BOOKING_ATTEMPT_HEADER = 'X-Booking-Attempt-Id';
|
|
359
|
+
const SLOW_REQUEST_THRESHOLD_MS = 2500;
|
|
360
|
+
const SUCCESS_TIMING_SAMPLE_RATE = 0.02;
|
|
358
361
|
|
|
359
362
|
function sleep(ms: number): Promise<void> {
|
|
360
363
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
function newBookingAttemptId(): string {
|
|
367
|
+
try {
|
|
368
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
369
|
+
return crypto.randomUUID();
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
/* fall through */
|
|
373
|
+
}
|
|
374
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getEndpointFromUrl(url: string): string {
|
|
378
|
+
try {
|
|
379
|
+
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
|
|
380
|
+
return parsed.pathname;
|
|
381
|
+
} catch {
|
|
382
|
+
return '';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isBookingCriticalEndpoint(endpoint: string): boolean {
|
|
387
|
+
return (
|
|
388
|
+
endpoint === '/1/get-availabilities' ||
|
|
389
|
+
endpoint === '/1/products' ||
|
|
390
|
+
endpoint === '/1/reserve' ||
|
|
391
|
+
endpoint.startsWith('/1/companies/')
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getConnectionDiagnostics(): Record<string, number | string | boolean> | null {
|
|
396
|
+
if (typeof navigator === 'undefined') return null;
|
|
397
|
+
const connection = (
|
|
398
|
+
navigator as Navigator & {
|
|
399
|
+
connection?: {
|
|
400
|
+
effectiveType?: string;
|
|
401
|
+
rtt?: number;
|
|
402
|
+
downlink?: number;
|
|
403
|
+
saveData?: boolean;
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
).connection;
|
|
407
|
+
if (!connection) return null;
|
|
408
|
+
return {
|
|
409
|
+
effectiveType: connection.effectiveType ?? 'unknown',
|
|
410
|
+
rtt: typeof connection.rtt === 'number' ? connection.rtt : -1,
|
|
411
|
+
downlink: typeof connection.downlink === 'number' ? connection.downlink : -1,
|
|
412
|
+
saveData: Boolean(connection.saveData),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getAvailabilityQueryShape(url: string): Record<string, string | boolean | string[]> | null {
|
|
417
|
+
try {
|
|
418
|
+
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
|
|
419
|
+
if (parsed.pathname !== '/1/get-availabilities') return null;
|
|
420
|
+
const params = parsed.searchParams;
|
|
421
|
+
return {
|
|
422
|
+
queryKeys: Array.from(params.keys()).sort(),
|
|
423
|
+
productId: params.get('productId') ?? '',
|
|
424
|
+
productOptionId: params.get('productOptionId') ?? '',
|
|
425
|
+
startDate: params.get('startDate') ?? '',
|
|
426
|
+
endDate: params.get('endDate') ?? '',
|
|
427
|
+
fromDateTime: params.get('fromDateTime') ?? '',
|
|
428
|
+
toDateTime: params.get('toDateTime') ?? '',
|
|
429
|
+
allOptions: params.get('allOptions') === 'true',
|
|
430
|
+
summary: params.get('summary') === 'true',
|
|
431
|
+
};
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function latestResourceTiming(url: string): Record<string, number | string | boolean> | null {
|
|
438
|
+
if (typeof performance === 'undefined') return null;
|
|
439
|
+
const entry = performance
|
|
440
|
+
.getEntriesByName(url)
|
|
441
|
+
.filter((item): item is PerformanceResourceTiming => item.entryType === 'resource')
|
|
442
|
+
.at(-1);
|
|
443
|
+
if (!entry) return null;
|
|
444
|
+
return {
|
|
445
|
+
name: entry.name,
|
|
446
|
+
durationMs: Math.round(entry.duration),
|
|
447
|
+
startTimeMs: Math.round(entry.startTime),
|
|
448
|
+
fetchStartMs: Math.round(entry.fetchStart),
|
|
449
|
+
domainLookupStartMs: Math.round(entry.domainLookupStart),
|
|
450
|
+
domainLookupEndMs: Math.round(entry.domainLookupEnd),
|
|
451
|
+
connectStartMs: Math.round(entry.connectStart),
|
|
452
|
+
connectEndMs: Math.round(entry.connectEnd),
|
|
453
|
+
secureConnectionStartMs: Math.round(entry.secureConnectionStart),
|
|
454
|
+
requestStartMs: Math.round(entry.requestStart),
|
|
455
|
+
responseStartMs: Math.round(entry.responseStart),
|
|
456
|
+
responseEndMs: Math.round(entry.responseEnd),
|
|
457
|
+
transferSize: entry.transferSize,
|
|
458
|
+
encodedBodySize: entry.encodedBodySize,
|
|
459
|
+
decodedBodySize: entry.decodedBodySize,
|
|
460
|
+
timingRestricted: entry.responseStart === 0 && entry.responseEnd === 0,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function bookingRequestTelemetryContext(
|
|
465
|
+
url: string,
|
|
466
|
+
endpoint: string,
|
|
467
|
+
headers: Record<string, string>,
|
|
468
|
+
requestAttemptId: string,
|
|
469
|
+
attemptNumber: number,
|
|
470
|
+
elapsedMs?: number,
|
|
471
|
+
): Record<string, unknown> {
|
|
472
|
+
const traceparent = headers.traceparent;
|
|
473
|
+
const traceId = traceparent ? traceIdFromTraceparent(traceparent) : undefined;
|
|
474
|
+
return {
|
|
475
|
+
endpoint,
|
|
476
|
+
method: 'GET',
|
|
477
|
+
correlationId: headers[BOOKING_CORRELATION_HEADER],
|
|
478
|
+
traceparent,
|
|
479
|
+
...(traceId ? { traceId } : {}),
|
|
480
|
+
requestAttemptId,
|
|
481
|
+
attemptNumber,
|
|
482
|
+
maxRetries: BOOKING_GET_MAX_RETRIES,
|
|
483
|
+
requestUrlPath: endpoint,
|
|
484
|
+
requestUrlSearchLength: (() => {
|
|
485
|
+
try {
|
|
486
|
+
return new URL(url, window.location.href).search.length;
|
|
487
|
+
} catch {
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
})(),
|
|
491
|
+
availabilityQueryShape: getAvailabilityQueryShape(url),
|
|
492
|
+
visibilityState: typeof document !== 'undefined' ? document.visibilityState : undefined,
|
|
493
|
+
pageAgeMs: typeof performance !== 'undefined' ? Math.round(performance.now()) : undefined,
|
|
494
|
+
connection: getConnectionDiagnostics(),
|
|
495
|
+
...(elapsedMs != null ? { elapsedMs } : {}),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
363
499
|
async function fetchBookingGetWithRetry(
|
|
364
500
|
url: string,
|
|
365
501
|
extra?: Pick<RequestInit, 'cache' | 'signal'>,
|
|
366
502
|
): Promise<Response> {
|
|
367
503
|
let lastErr: unknown;
|
|
504
|
+
const endpoint = getEndpointFromUrl(url);
|
|
368
505
|
for (let attempt = 0; attempt <= BOOKING_GET_MAX_RETRIES; attempt++) {
|
|
369
506
|
if (attempt > 0) {
|
|
370
507
|
await sleep(250 * attempt);
|
|
371
508
|
}
|
|
509
|
+
const requestAttemptId = newBookingAttemptId();
|
|
510
|
+
const headers = {
|
|
511
|
+
...getAuthHeaders(),
|
|
512
|
+
[BOOKING_ATTEMPT_HEADER]: requestAttemptId,
|
|
513
|
+
};
|
|
514
|
+
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
515
|
+
if (isBookingCriticalEndpoint(endpoint)) {
|
|
516
|
+
reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_STARTED', {
|
|
517
|
+
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
372
520
|
try {
|
|
373
521
|
const res = await fetch(url, {
|
|
374
522
|
...extra,
|
|
375
523
|
method: 'GET',
|
|
376
|
-
headers
|
|
524
|
+
headers,
|
|
377
525
|
});
|
|
526
|
+
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
|
|
527
|
+
if (
|
|
528
|
+
elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ||
|
|
529
|
+
(isBookingCriticalEndpoint(endpoint) && Math.random() < SUCCESS_TIMING_SAMPLE_RATE)
|
|
530
|
+
) {
|
|
531
|
+
reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_TIMING', {
|
|
532
|
+
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
|
|
533
|
+
httpStatus: res.status,
|
|
534
|
+
ok: res.ok,
|
|
535
|
+
resourceTiming: latestResourceTiming(url),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
378
538
|
if (res.ok) {
|
|
379
539
|
return res;
|
|
380
540
|
}
|
|
@@ -388,8 +548,28 @@ async function fetchBookingGetWithRetry(
|
|
|
388
548
|
} catch (e) {
|
|
389
549
|
lastErr = e;
|
|
390
550
|
if (attempt < BOOKING_GET_MAX_RETRIES) {
|
|
551
|
+
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
|
|
552
|
+
reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_RETRYING', {
|
|
553
|
+
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
|
|
554
|
+
errorName: e instanceof Error ? e.name : undefined,
|
|
555
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
556
|
+
requestSignalAborted: Boolean(extra?.signal?.aborted),
|
|
557
|
+
});
|
|
391
558
|
continue;
|
|
392
559
|
}
|
|
560
|
+
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
|
|
561
|
+
reportBookingClientTelemetryEvent('BOOKING_DIALOG_NETWORK_DIAGNOSTIC', {
|
|
562
|
+
...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
|
|
563
|
+
errorName: e instanceof Error ? e.name : undefined,
|
|
564
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
565
|
+
errorCause: e instanceof Error && e.cause ? String(e.cause) : undefined,
|
|
566
|
+
requestSignalAborted: Boolean(extra?.signal?.aborted),
|
|
567
|
+
abortReason:
|
|
568
|
+
extra?.signal && 'reason' in extra.signal
|
|
569
|
+
? String(extra.signal.reason ?? '')
|
|
570
|
+
: undefined,
|
|
571
|
+
resourceTiming: latestResourceTiming(url),
|
|
572
|
+
});
|
|
393
573
|
throw e;
|
|
394
574
|
}
|
|
395
575
|
}
|
|
@@ -1298,6 +1478,7 @@ export interface Availability {
|
|
|
1298
1478
|
pricesByCategory?: {
|
|
1299
1479
|
retailPrices: Array<{ category: string; price: number }>;
|
|
1300
1480
|
};
|
|
1481
|
+
isSummary?: boolean;
|
|
1301
1482
|
}
|
|
1302
1483
|
|
|
1303
1484
|
export type PrecomputedPricesByCategory = Record<string, Record<string, number>>;
|
|
@@ -1306,13 +1487,16 @@ export interface GetAvailabilitiesResponse {
|
|
|
1306
1487
|
availabilities: Availability[];
|
|
1307
1488
|
pricingConfig?: PricingConfig;
|
|
1308
1489
|
precomputedPrices?: PrecomputedPricesByCategory;
|
|
1490
|
+
precomputedPricesByOption?: Record<string, PrecomputedPricesByCategory>;
|
|
1309
1491
|
resourcePriceByCurrency?: Record<string, number>;
|
|
1310
1492
|
resourcePriceByOption?: Record<string, Record<string, number>>;
|
|
1493
|
+
responseMode?: 'summary' | string;
|
|
1311
1494
|
}
|
|
1312
1495
|
|
|
1313
1496
|
export interface GetAvailabilitiesOptions {
|
|
1314
1497
|
promoCode?: string | null;
|
|
1315
1498
|
allOptions?: boolean;
|
|
1499
|
+
summary?: boolean;
|
|
1316
1500
|
/**
|
|
1317
1501
|
* When set, TicketBooth should return rates/precomputed prices for this partner pricing profile.
|
|
1318
1502
|
* Must match the profile linked on the partner (e.g. capabilities.pricingProfileId).
|
|
@@ -1359,6 +1543,7 @@ export async function getAvailabilities(
|
|
|
1359
1543
|
});
|
|
1360
1544
|
if (options?.promoCode?.trim()) params.set('promoCode', options.promoCode.trim());
|
|
1361
1545
|
if (options?.allOptions === true) params.set('allOptions', 'true');
|
|
1546
|
+
if (options?.summary === true) params.set('summary', 'true');
|
|
1362
1547
|
const pricingProfileId = options?.pricingProfileId?.trim();
|
|
1363
1548
|
if (pricingProfileId) params.set('pricingProfileId', pricingProfileId);
|
|
1364
1549
|
const cancellationPolicyProfileId = options?.cancellationPolicyProfileId?.trim();
|
|
@@ -1407,12 +1592,17 @@ export async function getAvailabilities(
|
|
|
1407
1592
|
}
|
|
1408
1593
|
|
|
1409
1594
|
const bookingData = (data as { data?: GetAvailabilitiesResponse } | null)?.data;
|
|
1595
|
+
const isSummary = options?.summary === true || bookingData?.responseMode === 'summary';
|
|
1410
1596
|
return {
|
|
1411
|
-
availabilities: bookingData?.availabilities ?? []
|
|
1597
|
+
availabilities: (bookingData?.availabilities ?? []).map((availability) =>
|
|
1598
|
+
isSummary ? { ...availability, isSummary: true } : availability
|
|
1599
|
+
),
|
|
1412
1600
|
pricingConfig: bookingData?.pricingConfig,
|
|
1413
1601
|
precomputedPrices: bookingData?.precomputedPrices,
|
|
1602
|
+
precomputedPricesByOption: bookingData?.precomputedPricesByOption,
|
|
1414
1603
|
resourcePriceByCurrency: bookingData?.resourcePriceByCurrency,
|
|
1415
1604
|
resourcePriceByOption: bookingData?.resourcePriceByOption,
|
|
1605
|
+
responseMode: bookingData?.responseMode,
|
|
1416
1606
|
};
|
|
1417
1607
|
}
|
|
1418
1608
|
|
|
@@ -1649,6 +1839,16 @@ export interface CheckoutBreakdown {
|
|
|
1649
1839
|
currency: string;
|
|
1650
1840
|
}
|
|
1651
1841
|
|
|
1842
|
+
export interface CheckoutDependentAddOnSelection {
|
|
1843
|
+
dependentAddOnProductId: string;
|
|
1844
|
+
dependentAddOnProductOptionId?: string;
|
|
1845
|
+
offeringId: string;
|
|
1846
|
+
slotStart: string;
|
|
1847
|
+
slotEnd: string;
|
|
1848
|
+
quantity: number;
|
|
1849
|
+
checkoutAnswers?: Record<string, string>;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1652
1852
|
export interface CreatePaymentIntentRequest {
|
|
1653
1853
|
productId: string;
|
|
1654
1854
|
optionId: string;
|
|
@@ -1667,6 +1867,7 @@ export interface CreatePaymentIntentRequest {
|
|
|
1667
1867
|
cancellationPolicyId?: string;
|
|
1668
1868
|
termsAcceptedAt?: string;
|
|
1669
1869
|
checkoutBreakdown?: CheckoutBreakdown;
|
|
1870
|
+
dependentAddOnSelection?: CheckoutDependentAddOnSelection;
|
|
1670
1871
|
itineraryDisplay?: ItineraryDisplayStep[];
|
|
1671
1872
|
skipConfirmationCommunications?: boolean;
|
|
1672
1873
|
disableAutoCommunications?: boolean;
|
|
@@ -1765,6 +1966,7 @@ export interface ConfirmFreeBookingRequest {
|
|
|
1765
1966
|
pickupLocationId?: string;
|
|
1766
1967
|
termsAcceptedAt?: string;
|
|
1767
1968
|
itineraryDisplay?: ItineraryDisplayStep[];
|
|
1969
|
+
dependentAddOnSelection?: CheckoutDependentAddOnSelection;
|
|
1768
1970
|
skipConfirmationCommunications?: boolean;
|
|
1769
1971
|
disableAutoCommunications?: boolean;
|
|
1770
1972
|
source?: string;
|
|
@@ -1846,6 +2048,7 @@ export interface ConfirmBookingWithoutPaymentRequest {
|
|
|
1846
2048
|
skipConfirmationCommunications?: boolean;
|
|
1847
2049
|
disableAutoCommunications?: boolean;
|
|
1848
2050
|
checkoutBreakdown: CheckoutBreakdown;
|
|
2051
|
+
dependentAddOnSelection?: CheckoutDependentAddOnSelection;
|
|
1849
2052
|
depositAmount: number;
|
|
1850
2053
|
balanceAmount: number;
|
|
1851
2054
|
totalAmount: number;
|
package/src/runtime/types.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface BookingRuntimeCatalog {
|
|
|
74
74
|
getProductDescription: (...args: any[]) => any;
|
|
75
75
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
76
|
buildMinimalProductFromConfig?: (...args: any[]) => any;
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
getStaticProductByIdOrSlug?: (...args: any[]) => any;
|
|
77
79
|
/** Image URL helper from the host catalog (e.g. Via Via `constants/images`). */
|
|
78
80
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
81
|
getImageUrl?: (...args: any[]) => any;
|