@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
@@ -39,6 +39,11 @@ import {
39
39
  parseAvailabilityDateTime,
40
40
  shouldSyncSelectedAvailability,
41
41
  } from './standard-booking-availability';
42
+ import { shouldRevalidateAvailabilityCache } from './availability-cache-policy';
43
+ import {
44
+ resolvePartnerPricingProfileId,
45
+ type PartnerPricingProfileOverride,
46
+ } from '../../lib/booking/partner-pricing-profile';
42
47
 
43
48
  interface UseStandardBookingAvailabilityParams {
44
49
  product: Product;
@@ -50,6 +55,7 @@ interface UseStandardBookingAvailabilityParams {
50
55
  companyTimezone: string;
51
56
  appliedPromoCode: string | null;
52
57
  pricingProfileIdForAvailabilities: string | null;
58
+ pricingProfileOverridesForAvailabilities?: readonly PartnerPricingProfileOverride[];
53
59
  cancellationPolicyProfileIdForAvailabilities: string | null;
54
60
  bookingCutoffNow: Date;
55
61
  bookingCutoffMinutes?: number | null;
@@ -151,7 +157,8 @@ export function useStandardBookingAvailability({
151
157
  isAdmin,
152
158
  companyTimezone,
153
159
  appliedPromoCode,
154
- pricingProfileIdForAvailabilities,
160
+ pricingProfileIdForAvailabilities: fallbackPricingProfileIdForAvailabilities,
161
+ pricingProfileOverridesForAvailabilities,
155
162
  cancellationPolicyProfileIdForAvailabilities,
156
163
  bookingCutoffNow,
157
164
  bookingCutoffMinutes,
@@ -178,6 +185,21 @@ export function useStandardBookingAvailability({
178
185
  const selectedDateHydrationInFlightKeyRef = useRef<string | null>(null);
179
186
  const lastVisibleRangeRef = useRef<DateRange | null>(null);
180
187
  const activeOptionsLength = activeOptions.length;
188
+ const pricingProfileResolutionDate = selectedDate || (visibleRange
189
+ ? formatInTimeZone(visibleRange.start, companyTimezone, 'yyyy-MM-dd')
190
+ : formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd'));
191
+ const pricingProfileIdForAvailabilities = useMemo(
192
+ () => resolvePartnerPricingProfileId(
193
+ fallbackPricingProfileIdForAvailabilities,
194
+ pricingProfileOverridesForAvailabilities,
195
+ pricingProfileResolutionDate,
196
+ ),
197
+ [
198
+ fallbackPricingProfileIdForAvailabilities,
199
+ pricingProfileOverridesForAvailabilities,
200
+ pricingProfileResolutionDate,
201
+ ],
202
+ );
181
203
 
182
204
  const applyPricingConfig = useCallback((next?: PricingConfig | null) => {
183
205
  setPricingConfig((prev) => {
@@ -414,6 +436,14 @@ export function useStandardBookingAvailability({
414
436
  }
415
437
  }, [companyTimezone, visibleRange]);
416
438
 
439
+ useEffect(() => {
440
+ fetchedRangesRef.current = [];
441
+ }, [
442
+ appliedPromoCode,
443
+ pricingProfileIdForAvailabilities,
444
+ cancellationPolicyProfileIdForAvailabilities,
445
+ ]);
446
+
417
447
  useEffect(() => {
418
448
  if (isPartialLaunch) {
419
449
  setLoadingAvailabilities(false);
@@ -462,7 +492,10 @@ export function useStandardBookingAvailability({
462
492
  );
463
493
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
464
494
  if (cacheCoversRange) {
465
- shouldRevalidateCachedRange = true;
495
+ shouldRevalidateCachedRange = shouldRevalidateAvailabilityCache(
496
+ cacheCoversRange,
497
+ isStale,
498
+ );
466
499
  setAvailabilities(cached.availabilities);
467
500
  if (cached.availabilities.length > 0) {
468
501
  hasLoadedAvailabilitiesRef.current = true;
@@ -601,14 +634,6 @@ export function useStandardBookingAvailability({
601
634
  visibleRange,
602
635
  ]);
603
636
 
604
- useEffect(() => {
605
- fetchedRangesRef.current = [];
606
- }, [
607
- appliedPromoCode,
608
- pricingProfileIdForAvailabilities,
609
- cancellationPolicyProfileIdForAvailabilities,
610
- ]);
611
-
612
637
  const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
613
638
  const lastRange = lastVisibleRangeRef.current;
614
639
  const rangeChanged =
@@ -793,6 +818,7 @@ export function useStandardBookingAvailability({
793
818
  selectedReturnOption,
794
819
  setSelectedReturnOption,
795
820
  selectedDate,
821
+ pricingProfileIdForAvailabilities,
796
822
  setSelectedDate,
797
823
  loadingAvailabilities,
798
824
  isFetchingMoreAvailabilities,
@@ -7,7 +7,6 @@ const doubleCheckIconPath = '/pill-value-icons/double-check-icon.svg';
7
7
  const hikerIconPath = '/pill-value-icons/hiker-icon.svg';
8
8
  const waterIconPath = '/pill-value-icons/water-icon.svg';
9
9
  const lunchIconPath = '/pill-value-icons/lunch-icon.svg';
10
- const croissantIconPath = '/pill-value-icons/croissant-icon.svg';
11
10
  const locationPinIconPath = '/pill-value-icons/location-pin-icon.svg';
12
11
  const addTimeIconPath = '/pill-value-icons/add-time-icon.svg';
13
12
  const coffeeIconPath = '/pill-value-icons/coffee-icon.svg';
@@ -188,13 +187,6 @@ export const createLunchPillValue = (strings = defaultStrings): PillValue => {
188
187
  };
189
188
  }
190
189
 
191
- export const createCroissantPillValue = (strings = defaultStrings): PillValue => {
192
- return {
193
- icon: croissantIconPath,
194
- label: strings.pillValues.croissant
195
- };
196
- }
197
-
198
190
  export const createHotDrinksPillValue = (strings = defaultStrings): PillValue => {
199
191
  return {
200
192
  icon: coffeeIconPath,
@@ -1,5 +1,5 @@
1
1
  import { ImageData, IMAGES } from './images';
2
- import { PillValue, createDeparturePillValue, createDurationPillValue, createSunrisePillValue, createTwoLakesInOnePillValue, createHikePillValue, createCanoePillValue, createMoneyPillValue, createAddTimePillValue, createHotDrinksPillValue, createLunchPillValue, createCroissantPillValue, createemeraldLakeEscapeTourLocationsPillValues, createCozyBlanketsPillValue } from './pill-values';
2
+ import { PillValue, createDeparturePillValue, createDurationPillValue, createSunrisePillValue, createTwoLakesInOnePillValue, createHikePillValue, createCanoePillValue, createMoneyPillValue, createAddTimePillValue, createHotDrinksPillValue, createLunchPillValue, createemeraldLakeEscapeTourLocationsPillValues, createCozyBlanketsPillValue } from './pill-values';
3
3
  export enum ProductTagStyle {
4
4
  MOST_POPULAR = 'most-popular',
5
5
  NEW = 'new',
@@ -149,7 +149,7 @@ export const getProducts = (strings: any): Record<string, Product> => ({
149
149
  description: strings.productThemePages.privateTours.description,
150
150
  path: '/private-shuttle',
151
151
  avgPrice: 1699,
152
- pillValues: [createDeparturePillValue('private-tour', strings), createDurationPillValue('private-tour', strings), createCroissantPillValue(strings), createHotDrinksPillValue(strings), createMoneyPillValue('private-tour', strings)]
152
+ pillValues: [createDeparturePillValue('private-tour', strings), createDurationPillValue('private-tour', strings), createHotDrinksPillValue(strings), createMoneyPillValue('private-tour', strings)]
153
153
  }
154
154
  });
155
155
 
@@ -35,8 +35,7 @@
35
35
  "• Trailsnacks 🍫",
36
36
  "• Water to refills - bring your water bottle 💧",
37
37
  "• Complimentary hot chocolate, coffee & tea ☕️",
38
- "• Fresh croissant from a local french-canadian bakery 🥐",
39
- "• Lunch options available 🍽️",
38
+ "• Breakfast and Lunch options available 🍽️",
40
39
  "• Phone chargers 🔋 ",
41
40
  "• Moraine Lake Access Fee 🏞️",
42
41
  "• Banff National Park Pass 🏞️"
package/src/index.ts CHANGED
@@ -10,6 +10,11 @@ export {
10
10
  type PublicPartnerAgent,
11
11
  type PublicStaffPortalSignInOption,
12
12
  } from './public-partners';
13
+ export {
14
+ parsePartnerPricingProfileOverrides,
15
+ resolvePartnerPricingProfileId,
16
+ type PartnerPricingProfileOverride,
17
+ } from './lib/booking/partner-pricing-profile';
13
18
 
14
19
  /** Canonical Via Via booking UI — same modules as `@/components/booking/*` on the site. */
15
20
  export { BookingFlow } from './components/booking/BookingFlow';
@@ -80,6 +80,7 @@
80
80
  "selectTimeAndTickets": "Please select a time and at least one ticket",
81
81
  "selectPickupLocation": "Please select a pickup location",
82
82
  "loadingTimes": "Loading available times...",
83
+ "confirmingAvailability": "Confirming availability...",
83
84
  "noAvailability": "No availability found for the next 30 days. Please check back later.",
84
85
  "seeFullTourDescription": "See full tour description",
85
86
  "seeFullAddOnDescription": "See full experience details",
@@ -80,6 +80,7 @@
80
80
  "selectTimeAndTickets": "Veuillez sélectionner une heure et au moins un billet",
81
81
  "selectPickupLocation": "Veuillez sélectionner un lieu de prise en charge",
82
82
  "loadingTimes": "Chargement des heures disponibles...",
83
+ "confirmingAvailability": "Confirmation des disponibilités...",
83
84
  "noAvailability": "Aucune disponibilité trouvée pour les 30 prochains jours. Veuillez réessayer plus tard.",
84
85
  "seeFullTourDescription": "Voir la description complète du circuit",
85
86
  "seeFullAddOnDescription": "Voir tous les détails de l'expérience",
@@ -0,0 +1,74 @@
1
+ export type PartnerPricingProfileOverride = {
2
+ pricingProfileId: string;
3
+ months?: number[];
4
+ startMonth?: number | null;
5
+ endMonth?: number | null;
6
+ };
7
+
8
+ function asRecord(value: unknown): Record<string, unknown> | null {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : null;
12
+ }
13
+
14
+ function validMonth(value: unknown): number | null {
15
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 12
16
+ ? value
17
+ : null;
18
+ }
19
+
20
+ export function parsePartnerPricingProfileOverrides(
21
+ value: unknown,
22
+ ): PartnerPricingProfileOverride[] {
23
+ if (!Array.isArray(value)) return [];
24
+ return value.flatMap((row) => {
25
+ const record = asRecord(row);
26
+ const pricingProfileId =
27
+ typeof record?.pricingProfileId === 'string' ? record.pricingProfileId.trim() : '';
28
+ if (!pricingProfileId) return [];
29
+ const months = Array.isArray(record?.months)
30
+ ? record.months.map(validMonth).filter((month): month is number => month != null)
31
+ : [];
32
+ const startMonth = validMonth(record?.startMonth);
33
+ const endMonth = validMonth(record?.endMonth);
34
+ return [{
35
+ pricingProfileId,
36
+ ...(months.length > 0 ? { months } : {}),
37
+ ...(startMonth != null ? { startMonth } : {}),
38
+ ...(endMonth != null ? { endMonth } : {}),
39
+ }];
40
+ });
41
+ }
42
+
43
+ function bookingMonth(value: string | null | undefined): number | null {
44
+ const normalized = value?.trim();
45
+ if (!normalized) return null;
46
+ const match = /^(?:\d{4})-(\d{2})(?:-\d{2})?/.exec(normalized);
47
+ if (!match) return null;
48
+ const month = Number(match[1]);
49
+ return Number.isInteger(month) && month >= 1 && month <= 12 ? month : null;
50
+ }
51
+
52
+ function overrideMatchesMonth(override: PartnerPricingProfileOverride, month: number): boolean {
53
+ if (override.months?.length) return override.months.includes(month);
54
+ const start = override.startMonth;
55
+ const end = override.endMonth;
56
+ if (start == null || end == null) return false;
57
+ return start <= end ? month >= start && month <= end : month >= start || month <= end;
58
+ }
59
+
60
+ /** First matching seasonal override wins; the single profile remains the fallback. */
61
+ export function resolvePartnerPricingProfileId(
62
+ fallbackPricingProfileId: string | null | undefined,
63
+ overrides: readonly PartnerPricingProfileOverride[] | null | undefined,
64
+ bookingDate: string | null | undefined,
65
+ ): string | null {
66
+ const fallback = fallbackPricingProfileId?.trim() || null;
67
+ const month = bookingMonth(bookingDate);
68
+ if (month == null) return fallback;
69
+ const matchingProfile = overrides
70
+ ?.find((override) => overrideMatchesMonth(override, month))
71
+ ?.pricingProfileId
72
+ ?.trim();
73
+ return matchingProfile || fallback;
74
+ }
@@ -0,0 +1,138 @@
1
+ export interface ReservationAttemptStorage {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+
7
+ export interface ReservationAttemptContext<T extends object> {
8
+ idempotencyKey: string;
9
+ request: T & { idempotencyKey: string };
10
+ clear: () => void;
11
+ }
12
+
13
+ interface StoredReservationAttempt {
14
+ idempotencyKey: string;
15
+ createdAt: number;
16
+ }
17
+
18
+ export function reservationIdempotencyEnabled(
19
+ value: string | undefined = process.env.NEXT_PUBLIC_RESERVATION_IDEMPOTENCY_ENABLED
20
+ ): boolean {
21
+ return value?.trim().toLowerCase() === 'true';
22
+ }
23
+
24
+ export async function getOrCreateReservationAttempt<T extends object>(
25
+ request: T,
26
+ options: {
27
+ storage?: ReservationAttemptStorage | null;
28
+ now?: number;
29
+ createId?: () => string;
30
+ digest?: (value: string) => Promise<string>;
31
+ } = {}
32
+ ): Promise<ReservationAttemptContext<T>> {
33
+ const now = options.now ?? Date.now();
34
+ const requestWithoutKey = { ...request } as T & { idempotencyKey?: string };
35
+ delete requestWithoutKey.idempotencyKey;
36
+ const digest = options.digest ?? sha256Hex;
37
+ const fingerprint = await digest(stableStringify(requestWithoutKey));
38
+ const storageKey = `${STORAGE_PREFIX}${fingerprint}`;
39
+ const storage = options.storage === undefined ? browserSessionStorage() : options.storage;
40
+ const stored = readStoredAttempt(storage, storageKey, now);
41
+ const idempotencyKey = stored?.idempotencyKey ?? (options.createId ?? createAttemptId)();
42
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
43
+ throw new Error('Could not create a valid reservation attempt identifier.');
44
+ }
45
+ if (!stored) {
46
+ writeStoredAttempt(storage, storageKey, { idempotencyKey, createdAt: now });
47
+ }
48
+ return {
49
+ idempotencyKey,
50
+ request: { ...requestWithoutKey, idempotencyKey },
51
+ clear: () => storage?.removeItem(storageKey),
52
+ };
53
+ }
54
+
55
+ function browserSessionStorage(): ReservationAttemptStorage | null {
56
+ try {
57
+ return typeof window !== 'undefined' ? window.sessionStorage : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function readStoredAttempt(
64
+ storage: ReservationAttemptStorage | null,
65
+ key: string,
66
+ now: number
67
+ ): StoredReservationAttempt | null {
68
+ if (!storage) return null;
69
+ try {
70
+ const parsed = JSON.parse(storage.getItem(key) || '') as Partial<StoredReservationAttempt>;
71
+ if (
72
+ typeof parsed.idempotencyKey !== 'string' ||
73
+ !IDEMPOTENCY_KEY_PATTERN.test(parsed.idempotencyKey) ||
74
+ typeof parsed.createdAt !== 'number' ||
75
+ now - parsed.createdAt < 0 ||
76
+ now - parsed.createdAt > ATTEMPT_TTL_MS
77
+ ) {
78
+ storage.removeItem(key);
79
+ return null;
80
+ }
81
+ return { idempotencyKey: parsed.idempotencyKey, createdAt: parsed.createdAt };
82
+ } catch {
83
+ storage.removeItem(key);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function writeStoredAttempt(
89
+ storage: ReservationAttemptStorage | null,
90
+ key: string,
91
+ attempt: StoredReservationAttempt
92
+ ): void {
93
+ if (!storage) return;
94
+ try {
95
+ storage.setItem(key, JSON.stringify(attempt));
96
+ } catch {
97
+ // A storage failure reduces reload recovery but must not block a protected in-memory attempt.
98
+ }
99
+ }
100
+
101
+ function createAttemptId(): string {
102
+ const cryptoApi = globalThis.crypto;
103
+ if (typeof cryptoApi?.randomUUID === 'function') {
104
+ return `attempt_${cryptoApi.randomUUID().replaceAll('-', '')}`;
105
+ }
106
+ if (typeof cryptoApi?.getRandomValues === 'function') {
107
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
108
+ return `attempt_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`;
109
+ }
110
+ throw new Error('Secure random identifiers are unavailable in this browser.');
111
+ }
112
+
113
+ async function sha256Hex(value: string): Promise<string> {
114
+ if (!globalThis.crypto?.subtle) {
115
+ throw new Error('Secure request fingerprinting is unavailable in this browser.');
116
+ }
117
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
118
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
119
+ }
120
+
121
+ function stableStringify(value: unknown): string {
122
+ if (Array.isArray(value)) {
123
+ return `[${value.map(stableStringify).join(',')}]`;
124
+ }
125
+ if (value && typeof value === 'object') {
126
+ const entries = Object.entries(value as Record<string, unknown>)
127
+ .filter(([, child]) => child !== undefined)
128
+ .sort(([left], [right]) => left.localeCompare(right));
129
+ return `{${entries
130
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`)
131
+ .join(',')}}`;
132
+ }
133
+ return JSON.stringify(value);
134
+ }
135
+
136
+ const STORAGE_PREFIX = 'viavia:reservation-attempt:';
137
+ const ATTEMPT_TTL_MS = 30 * 60 * 1000;
138
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;