@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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +12 -7
- package/src/components/booking/BookingDialog.tsx +9 -4
- package/src/components/booking/BookingProductGrid.module.css +22 -10
- package/src/components/booking/BookingProductGrid.tsx +2 -2
- package/src/components/booking/ChangeBookingDialog.tsx +3 -1
- package/src/components/booking/ChangeBookingFlow.tsx +1 -1
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
- package/src/components/booking/NewBookingFlow.tsx +6 -2
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
- package/src/components/booking/TicketSelector.module.css +8 -0
- package/src/components/booking/TicketSelector.tsx +8 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
- package/src/components/booking/availability-cache-policy.ts +10 -0
- package/src/components/booking/booking-flow-types.ts +3 -0
- package/src/components/booking/booking-flow-ui.ts +10 -0
- package/src/components/booking/use-private-shuttle-availability.ts +5 -1
- package/src/components/booking/use-standard-booking-availability.ts +36 -10
- package/src/constants/pill-values.ts +0 -8
- package/src/constants/products.ts +2 -2
- package/src/data/product-descriptions/private-tour.en.json +1 -2
- package/src/index.ts +5 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking/partner-pricing-profile.ts +74 -0
- package/src/lib/booking/reservation-attempt.ts +138 -0
- package/src/lib/booking-api.ts +297 -71
- package/src/lib/env.ts +13 -0
- package/src/providers/booking-dialog-provider.tsx +3 -2
- package/src/public-partners.ts +12 -1
- package/src/runtime/types.ts +4 -0
- package/src/strings/en.json +1 -2
- package/src/strings/es.json +1 -2
- package/src/strings/fr.json +1 -2
- package/test/change-booking-helpers.test.ts +181 -1
- 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 =
|
|
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,
|
|
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),
|
|
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
|
-
"•
|
|
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}$/;
|