@ticketboothapp/booking 1.2.180 → 1.2.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingFlow.tsx +12 -7
  3. package/src/components/booking/BookingDialog.tsx +9 -4
  4. package/src/components/booking/BookingProductGrid.module.css +22 -10
  5. package/src/components/booking/BookingProductGrid.tsx +2 -2
  6. package/src/components/booking/ChangeBookingDialog.tsx +3 -1
  7. package/src/components/booking/ChangeBookingFlow.tsx +1 -1
  8. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
  9. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
  10. package/src/components/booking/NewBookingFlow.tsx +6 -2
  11. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
  12. package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
  13. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
  14. package/src/components/booking/TicketSelector.module.css +8 -0
  15. package/src/components/booking/TicketSelector.tsx +8 -0
  16. package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
  17. package/src/components/booking/availability-cache-policy.ts +10 -0
  18. package/src/components/booking/booking-flow-types.ts +3 -0
  19. package/src/components/booking/booking-flow-ui.ts +10 -0
  20. package/src/components/booking/use-private-shuttle-availability.ts +5 -1
  21. package/src/components/booking/use-standard-booking-availability.ts +36 -10
  22. package/src/constants/pill-values.ts +0 -8
  23. package/src/constants/products.ts +2 -2
  24. package/src/data/product-descriptions/private-tour.en.json +1 -2
  25. package/src/index.ts +5 -0
  26. package/src/lib/booking/i18n/messages/en.json +1 -0
  27. package/src/lib/booking/i18n/messages/fr.json +1 -0
  28. package/src/lib/booking/partner-pricing-profile.ts +74 -0
  29. package/src/lib/booking/reservation-attempt.ts +138 -0
  30. package/src/lib/booking-api.ts +297 -71
  31. package/src/lib/env.ts +13 -0
  32. package/src/providers/booking-dialog-provider.tsx +3 -2
  33. package/src/public-partners.ts +12 -1
  34. package/src/runtime/types.ts +4 -0
  35. package/src/strings/en.json +1 -2
  36. package/src/strings/es.json +1 -2
  37. package/src/strings/fr.json +1 -2
  38. package/test/change-booking-helpers.test.ts +181 -1
  39. package/test/partner-pricing-profile.test.ts +46 -0
@@ -75,7 +75,7 @@ interface BookingDialogProviderProps {
75
75
 
76
76
  export function BookingDialogProvider({ children }: BookingDialogProviderProps) {
77
77
  const host = useBookingHostOptional();
78
- const apiUrl = host?.env.API_URL;
78
+ const apiUrl = host?.env.BOOKING_READ_API_URL ?? host?.env.API_URL;
79
79
 
80
80
  const reportSuspiciousBookingProductId = useCallback(
81
81
  (original: string, sanitized: string): void => {
@@ -84,6 +84,7 @@ export function BookingDialogProvider({ children }: BookingDialogProviderProps)
84
84
  const correlationId = getOrCreateBookingCorrelationId();
85
85
  const event = {
86
86
  event: 'BOOKING_DIALOG_SUSPICIOUS_PRODUCT_ID',
87
+ clientBuildId: host?.env.BOOKING_CLIENT_BUILD_ID ?? 'source-unknown',
87
88
  endpoint: '/booking-open',
88
89
  correlationId,
89
90
  originalProductId: original,
@@ -103,7 +104,7 @@ export function BookingDialogProvider({ children }: BookingDialogProviderProps)
103
104
  keepalive: true,
104
105
  }).catch(() => {});
105
106
  },
106
- [apiUrl]
107
+ [apiUrl, host?.env.BOOKING_CLIENT_BUILD_ID]
107
108
  );
108
109
 
109
110
  const [isOpen, setIsOpen] = useState(false);
@@ -3,6 +3,11 @@
3
3
  * Uses NEXT_PUBLIC_* env from the consuming Next app at build time.
4
4
  */
5
5
 
6
+ import {
7
+ parsePartnerPricingProfileOverrides,
8
+ type PartnerPricingProfileOverride,
9
+ } from './lib/booking/partner-pricing-profile';
10
+
6
11
  function apiBase(): string {
7
12
  const u = process.env.NEXT_PUBLIC_API_URL;
8
13
  if (!u) {
@@ -51,6 +56,8 @@ export interface PublicPartner {
51
56
  anonymousBookAllowed?: boolean;
52
57
  /** B2B / partner pricing profile id when the API exposes it (optional). */
53
58
  pricingProfileId?: string;
59
+ /** Month-specific profiles; first matching rule wins and pricingProfileId is the fallback. */
60
+ pricingProfileOverrides?: PartnerPricingProfileOverride[];
54
61
  /** Partner cancellation-policy profile id when the API exposes it (optional). */
55
62
  cancellationPolicyProfileId?: string;
56
63
  }
@@ -67,7 +74,7 @@ export interface PublicStaffPortalSignInOption {
67
74
  displayName: string;
68
75
  }
69
76
 
70
- function mapPartnerApiRow(
77
+ export function mapPartnerApiRow(
71
78
  r: Record<string, unknown> & { partnerId: string; name: string },
72
79
  ): PublicPartner {
73
80
  const pickupLocationIds = parsePickupLocationIds(r);
@@ -83,6 +90,9 @@ function mapPartnerApiRow(
83
90
  ? cap.pricingProfileId
84
91
  : '';
85
92
  const pricingProfileId = rawProfile.trim() || undefined;
93
+ const pricingProfileOverrides = parsePartnerPricingProfileOverrides(
94
+ r.pricingProfileOverrides ?? cap?.pricingProfileOverrides,
95
+ );
86
96
  const rawCancellationProfile =
87
97
  typeof r.cancellationPolicyProfileId === 'string'
88
98
  ? r.cancellationPolicyProfileId
@@ -96,6 +106,7 @@ function mapPartnerApiRow(
96
106
  anonymousBookAllowed,
97
107
  ...(pickupLocationIds?.length ? { pickupLocationIds } : {}),
98
108
  ...(pricingProfileId ? { pricingProfileId } : {}),
109
+ ...(pricingProfileOverrides.length > 0 ? { pricingProfileOverrides } : {}),
99
110
  ...(cancellationPolicyProfileId ? { cancellationPolicyProfileId } : {}),
100
111
  };
101
112
  }
@@ -10,6 +10,10 @@ export type BookingSlotComponent = ComponentType<any>;
10
10
  */
11
11
  export interface BookingRuntimeEnv {
12
12
  readonly API_URL: string;
13
+ /** Optional same-origin base for public booking reads and client telemetry. */
14
+ readonly BOOKING_READ_API_URL?: string;
15
+ /** Immutable source/build identifier attached to browser telemetry. */
16
+ readonly BOOKING_CLIENT_BUILD_ID: string;
13
17
  readonly COMPANY_ID: string;
14
18
  readonly GOOGLE_MAPS_API_KEY: string;
15
19
  readonly STRIPE_PUBLISHABLE_KEY: string;
@@ -593,7 +593,6 @@
593
593
  "hike": "Perfect for hiking",
594
594
  "canoe": "Rent a canoe",
595
595
  "lunch": "Lunch at Emerald Lake Lodge",
596
- "croissant": "Croissants included",
597
596
  "hotDrinks": "Hot drinks",
598
597
  "blankets": "Cozy blankets",
599
598
  "emeraldLakeEscapeTourLocations": {
@@ -1511,7 +1510,7 @@
1511
1510
  {
1512
1511
  "pagesIncluded": [],
1513
1512
  "question": "Do you provide breakfast during your tours?",
1514
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1513
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1515
1514
  },
1516
1515
  {
1517
1516
  "pagesIncluded": [],
@@ -392,7 +392,6 @@
392
392
  "hike": "Perfecto para senderismo",
393
393
  "canoe": "Alquila una canoa",
394
394
  "lunch": "Almuerzo en Emerald Lake Lodge",
395
- "croissant": "Crusanes incluidos",
396
395
  "hotDrinks": "Bebidas calientes",
397
396
  "blankets": "Mantas calentitas",
398
397
  "emeraldLakeEscapeTourLocations": {
@@ -1309,7 +1308,7 @@
1309
1308
  {
1310
1309
  "pagesIncluded": [],
1311
1310
  "question": "Do you provide breakfast during your tours?",
1312
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1311
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1313
1312
  },
1314
1313
  {
1315
1314
  "pagesIncluded": [],
@@ -392,7 +392,6 @@
392
392
  "hike": "Idéal pour randonner",
393
393
  "canoe": "Location de canoé",
394
394
  "lunch": "Déjeuner aux lodges du lac Émeraude",
395
- "croissant": "Croissants inclus",
396
395
  "hotDrinks": "Boissons chaudes",
397
396
  "blankets": "Mantes chaudes",
398
397
  "emeraldLakeEscapeTourLocations": {
@@ -1309,7 +1308,7 @@
1309
1308
  {
1310
1309
  "pagesIncluded": [],
1311
1310
  "question": "Do you provide breakfast during your tours?",
1312
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1311
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1313
1312
  },
1314
1313
  {
1315
1314
  "pagesIncluded": [],
@@ -10,6 +10,7 @@ import {
10
10
  type ChangeQuoteUiSlice,
11
11
  } from '../src/lib/booking/change-flow-pricing';
12
12
  import {
13
+ createReservation,
13
14
  mapAdminChangeBookingQuoteV2Data,
14
15
  type Availability,
15
16
  type ChangeBookingQuoteResponse,
@@ -22,6 +23,7 @@ import {
22
23
  findFirstDateWithBookableAvailability,
23
24
  selectedDateHasVisibleAvailability,
24
25
  } from '../src/components/booking/availability-date-selection';
26
+ import { shouldRevalidateAvailabilityCache } from '../src/components/booking/availability-cache-policy';
25
27
  import {
26
28
  buildChangeBookingServerPreview,
27
29
  labelAdminAmendmentPriceSummaryLines,
@@ -71,6 +73,11 @@ import {
71
73
  resolveHydratedPrivateShuttleAvailability,
72
74
  } from '../src/components/booking/private-shuttle-availability';
73
75
  import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
76
+ import {
77
+ getOrCreateReservationAttempt,
78
+ reservationIdempotencyEnabled,
79
+ type ReservationAttemptStorage,
80
+ } from '../src/lib/booking/reservation-attempt';
74
81
  import {
75
82
  formatReservationHoldTime,
76
83
  reservationHoldHasExpired,
@@ -91,7 +98,10 @@ import {
91
98
  buildAdminChangePayNowCheckoutModalData,
92
99
  shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
93
100
  } from '../src/components/booking/admin-change-payment-choice-runner';
94
- import { shouldLockExistingAddOnQuantities } from '../src/components/booking/admin-change-flow-state-helpers';
101
+ import {
102
+ filterAdminChangeCalendarAvailabilities,
103
+ shouldLockExistingAddOnQuantities,
104
+ } from '../src/components/booking/admin-change-flow-state-helpers';
95
105
  import { haveAddOnSelectionsChanged } from '../src/components/booking/useChangeBookingSelectionDetails';
96
106
  import {
97
107
  incompatibleAddOnSelections,
@@ -157,6 +167,12 @@ function test(name: string, fn: () => void | Promise<void>): void {
157
167
  }
158
168
  }
159
169
 
170
+ test('availability cache revalidates only when a covered range is stale', () => {
171
+ assert.equal(shouldRevalidateAvailabilityCache(true, false), false);
172
+ assert.equal(shouldRevalidateAvailabilityCache(true, true), true);
173
+ assert.equal(shouldRevalidateAvailabilityCache(false, true), false);
174
+ });
175
+
160
176
  function calendarAvailability(dateTime: string, vacancies: number): Availability {
161
177
  return { dateTime, vacancies, currency: 'CAD' };
162
178
  }
@@ -407,6 +423,48 @@ test('admin availability filtering removes only starts that have passed', () =>
407
423
  );
408
424
  });
409
425
 
426
+ test('admin change availability filtering keeps the in-progress booking date selectable', () => {
427
+ const now = new Date('2026-08-01T13:00:00-06:00');
428
+ const rows = [
429
+ calendarAvailability('2026-07-31T09:00:00-06:00', 4),
430
+ calendarAvailability('2026-08-01T09:00:00-06:00', 4),
431
+ calendarAvailability('2026-08-01T11:00:00-06:00', 4),
432
+ calendarAvailability('2026-08-02T09:00:00-06:00', 4),
433
+ ];
434
+
435
+ assert.deepEqual(
436
+ filterAdminChangeCalendarAvailabilities({
437
+ availabilities: rows,
438
+ originalDate: '2026-08-01',
439
+ companyTimezone: 'America/Edmonton',
440
+ now,
441
+ }).map((row) => row.dateTime),
442
+ [
443
+ '2026-08-01T09:00:00-06:00',
444
+ '2026-08-01T11:00:00-06:00',
445
+ '2026-08-02T09:00:00-06:00',
446
+ ],
447
+ );
448
+ });
449
+
450
+ test('admin change availability filtering keeps the normal time guard without an original date', () => {
451
+ const now = new Date('2026-08-01T13:00:00-06:00');
452
+ const rows = [
453
+ calendarAvailability('2026-08-01T09:00:00-06:00', 4),
454
+ calendarAvailability('2026-08-01T14:00:00-06:00', 4),
455
+ ];
456
+
457
+ assert.deepEqual(
458
+ filterAdminChangeCalendarAvailabilities({
459
+ availabilities: rows,
460
+ originalDate: null,
461
+ companyTimezone: 'America/Edmonton',
462
+ now,
463
+ }).map((row) => row.dateTime),
464
+ ['2026-08-01T14:00:00-06:00'],
465
+ );
466
+ });
467
+
410
468
  test('public availability filtering keeps its stricter advance cutoff', () => {
411
469
  const now = new Date('2026-07-21T17:00:00-06:00');
412
470
  const rows = [
@@ -2305,3 +2363,125 @@ test('private shuttle checkout receipt labels add-on variants and quantities', (
2305
2363
  { name: 'Croissants (Butter Croissant) \u00d7 2', totalAmount: 11.98 },
2306
2364
  ]);
2307
2365
  });
2366
+
2367
+ test('reservation attempt reuses one idempotency key for an identical retry', async () => {
2368
+ const values = new Map<string, string>();
2369
+ const storage: ReservationAttemptStorage = {
2370
+ getItem: (key) => values.get(key) ?? null,
2371
+ setItem: (key, value) => values.set(key, value),
2372
+ removeItem: (key) => values.delete(key),
2373
+ };
2374
+ const request = {
2375
+ productId: 'po_test123',
2376
+ dateTime: '2026-08-02T08:00:00-06:00',
2377
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2378
+ };
2379
+ const options = {
2380
+ storage,
2381
+ now: 1_000,
2382
+ createId: () => 'attempt_1234567890abcdef',
2383
+ digest: async (value: string) => `digest-${value.length}`,
2384
+ };
2385
+
2386
+ const first = await getOrCreateReservationAttempt(request, options);
2387
+ const retry = await getOrCreateReservationAttempt(request, {
2388
+ ...options,
2389
+ now: 2_000,
2390
+ createId: () => 'attempt_should_not_be_used',
2391
+ });
2392
+
2393
+ assert.equal(first.idempotencyKey, 'attempt_1234567890abcdef');
2394
+ assert.equal(retry.idempotencyKey, first.idempotencyKey);
2395
+ assert.equal(retry.request.idempotencyKey, first.idempotencyKey);
2396
+ });
2397
+
2398
+ test('completed reservation attempt is cleared before a later logical attempt', async () => {
2399
+ const values = new Map<string, string>();
2400
+ const storage: ReservationAttemptStorage = {
2401
+ getItem: (key) => values.get(key) ?? null,
2402
+ setItem: (key, value) => values.set(key, value),
2403
+ removeItem: (key) => values.delete(key),
2404
+ };
2405
+ const request = { productId: 'po_test123', dateTime: '2026-08-02', bookingItems: [] };
2406
+ const digest = async () => 'same-request';
2407
+ const first = await getOrCreateReservationAttempt(request, {
2408
+ storage,
2409
+ digest,
2410
+ createId: () => 'attempt_1234567890abcdef',
2411
+ });
2412
+ first.clear();
2413
+ const later = await getOrCreateReservationAttempt(request, {
2414
+ storage,
2415
+ digest,
2416
+ createId: () => 'attempt_fedcba0987654321',
2417
+ });
2418
+
2419
+ assert.equal(later.idempotencyKey, 'attempt_fedcba0987654321');
2420
+ });
2421
+
2422
+ test('reservation idempotency requires an explicit frontend build flag', () => {
2423
+ assert.equal(reservationIdempotencyEnabled(undefined), false);
2424
+ assert.equal(reservationIdempotencyEnabled('TRUE'), true);
2425
+ });
2426
+
2427
+ test('lost reserve response is recovered without submitting a second reservation', async () => {
2428
+ const originalFetch = globalThis.fetch;
2429
+ const originalFlag = process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED;
2430
+ const calls: Array<{ url: string; body: { data?: { idempotencyKey?: string } } }> = [];
2431
+ let scenario: 'network-loss' | 'gateway-timeout' = 'network-loss';
2432
+ process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED = 'true';
2433
+ globalThis.fetch = async (input, init) => {
2434
+ const url = String(input);
2435
+ const body = JSON.parse(String(init?.body ?? '{}')) as { data?: { idempotencyKey?: string } };
2436
+ calls.push({ url, body });
2437
+ if (url.endsWith('/1/reserve')) {
2438
+ if (scenario === 'network-loss') {
2439
+ throw new Error('connection closed after request was sent');
2440
+ }
2441
+ return new Response('Gateway timeout', { status: 504 });
2442
+ }
2443
+ return new Response(
2444
+ JSON.stringify({
2445
+ data: {
2446
+ reservationReference:
2447
+ scenario === 'network-loss' ? 'resRef_RECOVERED' : 'resRef_TIMEOUT_RECOVERED',
2448
+ reservationExpiration: '2026-08-01T18:10:00Z',
2449
+ },
2450
+ }),
2451
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
2452
+ );
2453
+ };
2454
+
2455
+ try {
2456
+ const result = await createReservation({
2457
+ productId: 'po_test123',
2458
+ dateTime: '2026-08-02T08:00:00-06:00',
2459
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2460
+ });
2461
+
2462
+ assert.equal(result.reservationReference, 'resRef_RECOVERED');
2463
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve')).length, 1);
2464
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve/status')).length, 1);
2465
+ assert.match(calls[0].body.data?.idempotencyKey ?? '', /^attempt_[a-f0-9]{32}$/);
2466
+ assert.equal(calls[1].body.data?.idempotencyKey, calls[0].body.data?.idempotencyKey);
2467
+
2468
+ scenario = 'gateway-timeout';
2469
+ calls.length = 0;
2470
+ const timeoutResult = await createReservation({
2471
+ productId: 'po_test123',
2472
+ dateTime: '2026-08-03T08:00:00-06:00',
2473
+ bookingItems: [{ category: 'ADULT', count: 2 }],
2474
+ });
2475
+
2476
+ assert.equal(timeoutResult.reservationReference, 'resRef_TIMEOUT_RECOVERED');
2477
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve')).length, 1);
2478
+ assert.equal(calls.filter((call) => call.url.endsWith('/1/reserve/status')).length, 1);
2479
+ } finally {
2480
+ globalThis.fetch = originalFetch;
2481
+ if (originalFlag === undefined) {
2482
+ delete process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED;
2483
+ } else {
2484
+ process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED = originalFlag;
2485
+ }
2486
+ }
2487
+ });
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { mapPartnerApiRow } from '../src/public-partners';
4
+ import {
5
+ parsePartnerPricingProfileOverrides,
6
+ resolvePartnerPricingProfileId,
7
+ } from '../src/lib/booking/partner-pricing-profile';
8
+
9
+ const overrides = parsePartnerPricingProfileOverrides([
10
+ {
11
+ pricingProfileId: 'pp_banff_adventures_july_aug_sept',
12
+ months: [7, 8, 9],
13
+ },
14
+ ]);
15
+
16
+ for (const bookingDate of ['2026-07-01', '2026-08-02', '2026-09-30']) {
17
+ assert.equal(
18
+ resolvePartnerPricingProfileId('pp_banff_adventures', overrides, bookingDate),
19
+ 'pp_banff_adventures_july_aug_sept',
20
+ );
21
+ }
22
+ for (const bookingDate of ['2026-06-30', '2026-10-01']) {
23
+ assert.equal(
24
+ resolvePartnerPricingProfileId('pp_banff_adventures', overrides, bookingDate),
25
+ 'pp_banff_adventures',
26
+ );
27
+ }
28
+
29
+ const mapped = mapPartnerApiRow({
30
+ partnerId: 'par_U4md9ZBsf3uR',
31
+ name: 'Banff Adventures',
32
+ capabilities: {
33
+ pricingProfileId: 'pp_banff_adventures',
34
+ pricingProfileOverrides: [
35
+ {
36
+ pricingProfileId: 'pp_banff_adventures_july_aug_sept',
37
+ months: [7, 8, 9],
38
+ },
39
+ ],
40
+ },
41
+ });
42
+
43
+ assert.equal(mapped.pricingProfileId, 'pp_banff_adventures');
44
+ assert.deepEqual(mapped.pricingProfileOverrides, overrides);
45
+
46
+ console.log('partner pricing profile tests passed');