@ticketboothapp/booking 1.2.149 → 1.2.151
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/PrivateShuttleBookingFlow.tsx +21 -11
- package/src/components/booking/private-shuttle-availability.ts +35 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +19 -1
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +6 -4
- package/src/lib/booking/change-booking-server-preview.ts +5 -1
- package/src/lib/booking/source-metadata.ts +74 -2
- package/src/lib/booking-api.ts +8 -4
- package/src/providers/booking-dialog-provider.tsx +3 -2
- package/test/change-booking-helpers.test.ts +82 -0
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
getPrivateShuttleAvailabilityOptionId,
|
|
34
34
|
privateShuttleBookingCutoffMessage,
|
|
35
35
|
privateShuttleStartTimeAllowed,
|
|
36
|
+
resolveHydratedPrivateShuttleAvailability,
|
|
36
37
|
} from './private-shuttle-availability';
|
|
37
38
|
import { usePrivateShuttleAvailability } from './use-private-shuttle-availability';
|
|
38
39
|
import type {
|
|
@@ -263,6 +264,15 @@ export function PrivateShuttleBookingFlow({
|
|
|
263
264
|
};
|
|
264
265
|
const selectedOptionConfig = activeOptions.find((opt) => opt.optionId === selectedOption);
|
|
265
266
|
const privateShuttleConfig = selectedOptionConfig?.privateShuttleConfig;
|
|
267
|
+
const effectiveSelectedAvailability = useMemo(
|
|
268
|
+
() =>
|
|
269
|
+
resolveHydratedPrivateShuttleAvailability(
|
|
270
|
+
selectedAvailability,
|
|
271
|
+
selectedDate ? (availabilitiesByDate[selectedDate] ?? []) : [],
|
|
272
|
+
selectedOption,
|
|
273
|
+
),
|
|
274
|
+
[availabilitiesByDate, selectedAvailability, selectedDate, selectedOption],
|
|
275
|
+
);
|
|
266
276
|
const selectedPickupLocation = useMemo(
|
|
267
277
|
() =>
|
|
268
278
|
pickupLocationId ? product.pickupLocations?.find((loc) => loc.id === pickupLocationId) : null,
|
|
@@ -290,7 +300,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
290
300
|
}, [draftItineraryDestinations]);
|
|
291
301
|
|
|
292
302
|
const suggestedStartTimes = useMemo(() => {
|
|
293
|
-
const times =
|
|
303
|
+
const times = effectiveSelectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
|
|
294
304
|
if (isAdmin || !selectedDate) return times;
|
|
295
305
|
return times.filter((time) =>
|
|
296
306
|
privateShuttleStartTimeAllowed(
|
|
@@ -307,7 +317,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
307
317
|
companyTimezone,
|
|
308
318
|
isAdmin,
|
|
309
319
|
privateShuttleConfig?.suggestedStartTimes,
|
|
310
|
-
|
|
320
|
+
effectiveSelectedAvailability?.suggestedStartTimes,
|
|
311
321
|
selectedDate,
|
|
312
322
|
]);
|
|
313
323
|
const {
|
|
@@ -338,7 +348,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
338
348
|
selectedOption,
|
|
339
349
|
selectedDate,
|
|
340
350
|
selectedStartTime,
|
|
341
|
-
selectedAvailability,
|
|
351
|
+
selectedAvailability: effectiveSelectedAvailability,
|
|
342
352
|
selectedOptionConfig,
|
|
343
353
|
activeOptions,
|
|
344
354
|
availabilitiesByDate,
|
|
@@ -469,8 +479,8 @@ export function PrivateShuttleBookingFlow({
|
|
|
469
479
|
// When availability changes, clip passenger count to new vacancies (e.g. user switched date/option).
|
|
470
480
|
// Don't run when passengerCount changes—allows special request for more than vacancies.
|
|
471
481
|
useEffect(() => {
|
|
472
|
-
if (!
|
|
473
|
-
const vacancies =
|
|
482
|
+
if (!effectiveSelectedAvailability) return;
|
|
483
|
+
const vacancies = effectiveSelectedAvailability.vacancies || 0;
|
|
474
484
|
const minP = 1;
|
|
475
485
|
setIsSpecialRequestMode(false);
|
|
476
486
|
setSpecialRequestInputValue('');
|
|
@@ -478,7 +488,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
478
488
|
if (prev <= vacancies) return Math.max(minP, prev);
|
|
479
489
|
return Math.max(minP, vacancies);
|
|
480
490
|
});
|
|
481
|
-
}, [
|
|
491
|
+
}, [effectiveSelectedAvailability]);
|
|
482
492
|
|
|
483
493
|
useEffect(() => {
|
|
484
494
|
if (selectedOption) {
|
|
@@ -561,7 +571,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
561
571
|
};
|
|
562
572
|
|
|
563
573
|
const handlePassengerCountChange = (count: number) => {
|
|
564
|
-
const max =
|
|
574
|
+
const max = effectiveSelectedAvailability?.vacancies || 0;
|
|
565
575
|
const min = 1;
|
|
566
576
|
setPassengerCount(Math.max(min, max > 0 ? Math.min(max, count) : count));
|
|
567
577
|
setError('');
|
|
@@ -642,7 +652,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
642
652
|
selectedOption,
|
|
643
653
|
selectedDate,
|
|
644
654
|
selectedStartTime,
|
|
645
|
-
selectedAvailability,
|
|
655
|
+
selectedAvailability: effectiveSelectedAvailability,
|
|
646
656
|
companyTimezone,
|
|
647
657
|
bookingCutoffNow,
|
|
648
658
|
bookingCutoffMinutes,
|
|
@@ -793,12 +803,12 @@ export function PrivateShuttleBookingFlow({
|
|
|
793
803
|
onOptionSelect={handleOptionSelect}
|
|
794
804
|
/>
|
|
795
805
|
|
|
796
|
-
{selectedOption &&
|
|
806
|
+
{selectedOption && effectiveSelectedAvailability && (
|
|
797
807
|
<PrivateShuttlePassengerSection
|
|
798
808
|
passengerCount={passengerCount}
|
|
799
809
|
resourceCount={resourceCount}
|
|
800
810
|
billableResourceCount={billableResourceCount}
|
|
801
|
-
maxVacancies={
|
|
811
|
+
maxVacancies={effectiveSelectedAvailability.vacancies || 0}
|
|
802
812
|
isSpecialRequestMode={isSpecialRequestMode}
|
|
803
813
|
specialRequestInputValue={specialRequestInputValue}
|
|
804
814
|
onPassengerCountChange={handlePassengerCountChange}
|
|
@@ -808,7 +818,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
808
818
|
/>
|
|
809
819
|
)}
|
|
810
820
|
|
|
811
|
-
{selectedOption &&
|
|
821
|
+
{selectedOption && effectiveSelectedAvailability && passengerCount > 0 && (
|
|
812
822
|
<PrivateShuttleStartTimeSection
|
|
813
823
|
suggestedStartTimes={suggestedStartTimes}
|
|
814
824
|
selectedStartTime={selectedStartTime}
|
|
@@ -87,6 +87,41 @@ export function getPrivateShuttleAvailabilityCacheKey(availability: Availability
|
|
|
87
87
|
return `${availability.dateTime}-${getPrivateShuttleAvailabilityOptionId(availability)}`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Calendar results are summary-only. Once the selected day is hydrated, prefer the
|
|
92
|
+
* matching detailed availability so pricing uses its authoritative dynamic adjustments.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveHydratedPrivateShuttleAvailability(
|
|
95
|
+
selected: Availability | null,
|
|
96
|
+
availabilitiesForSelectedDate: Availability[],
|
|
97
|
+
selectedOptionId: string,
|
|
98
|
+
): Availability | null {
|
|
99
|
+
if (!selected) return null;
|
|
100
|
+
const selectedAvailabilityId = selected.availabilityId?.trim();
|
|
101
|
+
const selectedOption = selectedOptionId.trim();
|
|
102
|
+
const hydrated = availabilitiesForSelectedDate.find((candidate) => {
|
|
103
|
+
if (
|
|
104
|
+
selectedAvailabilityId &&
|
|
105
|
+
candidate.availabilityId?.trim() !== selectedAvailabilityId
|
|
106
|
+
) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
!selectedOption ||
|
|
111
|
+
getPrivateShuttleAvailabilityOptionId(candidate) === selectedOption
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
return hydrated ?? selected;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function privateShuttlePriceChanged(
|
|
118
|
+
displayedTotal: number,
|
|
119
|
+
authoritativeTotal: number,
|
|
120
|
+
): boolean {
|
|
121
|
+
if (!Number.isFinite(displayedTotal) || !Number.isFinite(authoritativeTotal)) return true;
|
|
122
|
+
return Math.round(displayedTotal * 100) !== Math.round(authoritativeTotal * 100);
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
export function getResourceRate(availability: Availability | undefined | null) {
|
|
91
126
|
return availability?.rates?.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
|
|
92
127
|
}
|
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
} from '../../lib/booking/source-metadata';
|
|
17
17
|
import { quotePublicNewBookingV2ForCheckout } from '../../lib/pricing-v2-shadow';
|
|
18
18
|
import type { Currency } from './CurrencySwitcher';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
findMergedPrivateShuttleAvailability,
|
|
21
|
+
privateShuttlePriceChanged,
|
|
22
|
+
} from './private-shuttle-availability';
|
|
20
23
|
|
|
21
24
|
type PrivateShuttleBookingItem = { category: 'RESOURCE'; count: number };
|
|
22
25
|
export type PrivateShuttleAddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
|
|
@@ -213,6 +216,21 @@ export async function reservePrivateShuttleForCheckout({
|
|
|
213
216
|
},
|
|
214
217
|
});
|
|
215
218
|
|
|
219
|
+
const authoritativeTotal =
|
|
220
|
+
pricingV2Quote?.quote?.amountToCharge ??
|
|
221
|
+
pricingV2Quote?.quote?.payableTotal ??
|
|
222
|
+
pricingV2Quote?.quote?.totalAmount;
|
|
223
|
+
if (
|
|
224
|
+
typeof authoritativeTotal === 'number' &&
|
|
225
|
+
privateShuttlePriceChanged(totalPrice, authoritativeTotal)
|
|
226
|
+
) {
|
|
227
|
+
return {
|
|
228
|
+
kind: 'blocked',
|
|
229
|
+
message:
|
|
230
|
+
'Pricing was refreshed. Please review the updated total and continue to payment again.',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
216
234
|
const reservation = await createReservation({
|
|
217
235
|
productId: selectedOption,
|
|
218
236
|
dateTime: selectedDate,
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
|
-
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
5
5
|
import {
|
|
6
6
|
buildBookingSourceMetadataFromLocation,
|
|
7
7
|
type BookingSourceMetadata,
|
|
8
8
|
} from '../lib/booking/source-metadata';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Re-reads URL-derived booking attribution when the
|
|
12
|
-
* same
|
|
11
|
+
* Re-reads URL-derived booking attribution when either the path or query changes. This is
|
|
12
|
+
* important after same-path checkout-return cleanup removes transient Stripe parameters.
|
|
13
13
|
*/
|
|
14
14
|
export function useBookingSourceMetadataFromLocation(): Partial<BookingSourceMetadata> {
|
|
15
15
|
const pathname = usePathname() ?? '';
|
|
16
|
+
const searchParams = useSearchParams();
|
|
17
|
+
const query = searchParams.toString();
|
|
16
18
|
|
|
17
19
|
return useMemo(
|
|
18
20
|
() => buildBookingSourceMetadataFromLocation(),
|
|
19
|
-
[pathname],
|
|
21
|
+
[pathname, query],
|
|
20
22
|
);
|
|
21
23
|
}
|
|
@@ -199,7 +199,11 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
199
199
|
) {
|
|
200
200
|
const category =
|
|
201
201
|
type === 'TICKET'
|
|
202
|
-
?
|
|
202
|
+
? label.toUpperCase().startsWith('REFUND ·')
|
|
203
|
+
? label
|
|
204
|
+
: isPricingV2Line(item) && item.metadata?.category
|
|
205
|
+
? item.metadata.category.toUpperCase()
|
|
206
|
+
: (label.split(/\s+/)[0]?.replace(/[^a-zA-Z]/g, '') || 'TICKET').toUpperCase()
|
|
203
207
|
: type;
|
|
204
208
|
out.push({
|
|
205
209
|
kind: 'ticket',
|
|
@@ -49,6 +49,76 @@ export function isDedicatedPartnerBookingPortalHost(hostname: string): boolean {
|
|
|
49
49
|
return h === 'booking.viaviamorainelake.com' || h === 'staging.booking.viaviamorainelake.com';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const STRIPE_REDIRECT_QUERY_KEYS = new Set([
|
|
53
|
+
'payment_intent',
|
|
54
|
+
'payment_intent_client_secret',
|
|
55
|
+
'setup_intent',
|
|
56
|
+
'setup_intent_client_secret',
|
|
57
|
+
'redirect_status',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const CHECKOUT_RETURN_QUERY_KEYS = new Set([
|
|
61
|
+
'reservationref',
|
|
62
|
+
'reservation',
|
|
63
|
+
'bookingreference',
|
|
64
|
+
'ref',
|
|
65
|
+
'lastname',
|
|
66
|
+
'bookingdate',
|
|
67
|
+
'booking_complete',
|
|
68
|
+
'payment',
|
|
69
|
+
'dap_success',
|
|
70
|
+
'embed_manage',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function normalizedQueryKey(key: string): string {
|
|
74
|
+
return key.trim().toLowerCase().replace(/-/g, '_');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isSensitiveQueryKey(key: string): boolean {
|
|
78
|
+
const normalized = normalizedQueryKey(key);
|
|
79
|
+
const compact = normalized.replace(/_/g, '');
|
|
80
|
+
return (
|
|
81
|
+
STRIPE_REDIRECT_QUERY_KEYS.has(normalized) ||
|
|
82
|
+
['secret', 'token', 'password', 'passwd', 'authorization'].some((term) =>
|
|
83
|
+
compact.includes(term),
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Removes payment-return credentials and transient checkout identity from attribution URLs.
|
|
90
|
+
* Unknown non-sensitive query parameters remain available for configured partner attribution.
|
|
91
|
+
*/
|
|
92
|
+
export function sanitizeBookingSourceUrl(rawUrl: string | null | undefined): string | undefined {
|
|
93
|
+
const value = rawUrl?.trim();
|
|
94
|
+
if (!value) return undefined;
|
|
95
|
+
|
|
96
|
+
let url: URL;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL(value);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const originalKeys = Array.from(url.searchParams.keys()).map(normalizedQueryKey);
|
|
104
|
+
const isCheckoutReturn =
|
|
105
|
+
originalKeys.some((key) => STRIPE_REDIRECT_QUERY_KEYS.has(key)) ||
|
|
106
|
+
url.searchParams.get('embed_manage') === '1' ||
|
|
107
|
+
url.searchParams.get('booking_complete') === '1';
|
|
108
|
+
|
|
109
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
110
|
+
const normalized = normalizedQueryKey(key);
|
|
111
|
+
if (
|
|
112
|
+
isSensitiveQueryKey(normalized) ||
|
|
113
|
+
(isCheckoutReturn && CHECKOUT_RETURN_QUERY_KEYS.has(normalized))
|
|
114
|
+
) {
|
|
115
|
+
url.searchParams.delete(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return url.href;
|
|
120
|
+
}
|
|
121
|
+
|
|
52
122
|
function parseCookieMap(cookieString: string): Map<string, string> {
|
|
53
123
|
const decodeCookieValue = (value: string): string => {
|
|
54
124
|
try {
|
|
@@ -119,7 +189,9 @@ export function buildBookingSourceMetadataFromLocation(): Partial<BookingSourceM
|
|
|
119
189
|
return {};
|
|
120
190
|
}
|
|
121
191
|
|
|
122
|
-
const
|
|
192
|
+
const sanitizedPageUrl = sanitizeBookingSourceUrl(window.location.href);
|
|
193
|
+
if (!sanitizedPageUrl) return {};
|
|
194
|
+
const currentUrl = new URL(sanitizedPageUrl);
|
|
123
195
|
const params = currentUrl.searchParams;
|
|
124
196
|
const partnerMatch = currentUrl.pathname.match(/^\/partner\/([^/]+)/i);
|
|
125
197
|
const onPartnerEmbedPath = isPublicPartnerMarketingPath(currentUrl.pathname);
|
|
@@ -135,7 +207,7 @@ export function buildBookingSourceMetadataFromLocation(): Partial<BookingSourceM
|
|
|
135
207
|
pageUrl: currentUrl.href,
|
|
136
208
|
pagePath: currentUrl.pathname,
|
|
137
209
|
pageQuery: currentUrl.search || undefined,
|
|
138
|
-
referrerUrl: document.referrer
|
|
210
|
+
referrerUrl: sanitizeBookingSourceUrl(document.referrer),
|
|
139
211
|
utmSource: params.get('utm_source') || undefined,
|
|
140
212
|
utmMedium: params.get('utm_medium') || undefined,
|
|
141
213
|
utmCampaign: params.get('utm_campaign') || undefined,
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
normalizeBookingProductId,
|
|
18
18
|
} from './booking/normalize-booking-product-id';
|
|
19
19
|
import { ENV } from './env';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_BOOKING_SOURCE,
|
|
22
|
+
sanitizeBookingSourceUrl,
|
|
23
|
+
type BookingSourceMetadata,
|
|
24
|
+
} from './booking/source-metadata';
|
|
21
25
|
|
|
22
26
|
const API_BASE = ENV.API_URL;
|
|
23
27
|
|
|
@@ -105,7 +109,7 @@ function reportBookingClientTelemetryEvent(
|
|
|
105
109
|
traceparent,
|
|
106
110
|
...(traceId ? { traceId } : {}),
|
|
107
111
|
apiBase: API_BASE,
|
|
108
|
-
pageUrl: window.location.href,
|
|
112
|
+
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
109
113
|
userAgent: window.navigator.userAgent,
|
|
110
114
|
online: window.navigator.onLine,
|
|
111
115
|
occurredAt: new Date().toISOString(),
|
|
@@ -424,7 +428,7 @@ function getBrowserDiagnostics(): Record<string, number | string | boolean> {
|
|
|
424
428
|
siteOrigin: window.location.origin,
|
|
425
429
|
siteProtocol: window.location.protocol,
|
|
426
430
|
isSecureContext: window.isSecureContext,
|
|
427
|
-
referrer: document.referrer
|
|
431
|
+
referrer: sanitizeBookingSourceUrl(document.referrer) ?? '',
|
|
428
432
|
};
|
|
429
433
|
}
|
|
430
434
|
|
|
@@ -568,7 +572,7 @@ async function collectNetworkFailureProbeResults(
|
|
|
568
572
|
correlationId,
|
|
569
573
|
traceparent,
|
|
570
574
|
apiBase: API_BASE,
|
|
571
|
-
pageUrl: window.location.href,
|
|
575
|
+
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
572
576
|
userAgent: window.navigator.userAgent,
|
|
573
577
|
online: window.navigator.onLine,
|
|
574
578
|
occurredAt: new Date().toISOString(),
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
normalizeBookingProductId,
|
|
18
18
|
} from '../lib/booking/normalize-booking-product-id';
|
|
19
19
|
import { useBookingHostOptional } from '../runtime/BookingHostContext';
|
|
20
|
+
import { sanitizeBookingSourceUrl } from '../lib/booking/source-metadata';
|
|
20
21
|
|
|
21
22
|
/** Filter IDs for the product grid. Must match BookingProductGrid FILTER_IDS. */
|
|
22
23
|
export type ProductGridFilterId =
|
|
@@ -87,8 +88,8 @@ export function BookingDialogProvider({ children }: BookingDialogProviderProps)
|
|
|
87
88
|
correlationId,
|
|
88
89
|
originalProductId: original,
|
|
89
90
|
sanitizedProductId: sanitized,
|
|
90
|
-
pageUrl: window.location.href,
|
|
91
|
-
referrer: document.referrer
|
|
91
|
+
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
92
|
+
referrer: sanitizeBookingSourceUrl(document.referrer) ?? null,
|
|
92
93
|
userAgent: window.navigator.userAgent,
|
|
93
94
|
occurredAt: new Date().toISOString(),
|
|
94
95
|
stack: new Error('Suspicious booking productId input').stack ?? null,
|
|
@@ -8,6 +8,12 @@ import { buildCheckoutModalSummaryFromPricingV2Quote } from '../src/components/b
|
|
|
8
8
|
import type { ChangeQuoteUiSlice } from '../src/lib/booking/change-flow-pricing';
|
|
9
9
|
import type { ChangeBookingQuoteResponse } from '../src/lib/booking-api';
|
|
10
10
|
import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
|
|
11
|
+
import {
|
|
12
|
+
applyResourceAdjustments,
|
|
13
|
+
privateShuttlePriceChanged,
|
|
14
|
+
resolveHydratedPrivateShuttleAvailability,
|
|
15
|
+
} from '../src/components/booking/private-shuttle-availability';
|
|
16
|
+
import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
|
|
11
17
|
|
|
12
18
|
function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
|
|
13
19
|
return {
|
|
@@ -36,6 +42,36 @@ function test(name: string, fn: () => void): void {
|
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
|
|
46
|
+
const sanitized = sanitizeBookingSourceUrl(
|
|
47
|
+
'https://staging.booking.viaviamorainelake.com/?partnerId=par_safe&agentId=agent_safe&agentName=Isadora+Buttonmoss&embed_manage=1&reservationRef=resRef_previous&lastName=Tauro&bookingDate=2026-07-16&payment_intent=pi_previous&payment_intent_client_secret=do_not_store&redirect_status=succeeded&tab=bookings',
|
|
48
|
+
);
|
|
49
|
+
assert.ok(sanitized);
|
|
50
|
+
|
|
51
|
+
const url = new URL(sanitized);
|
|
52
|
+
assert.equal(url.searchParams.get('partnerId'), 'par_safe');
|
|
53
|
+
assert.equal(url.searchParams.get('agentId'), 'agent_safe');
|
|
54
|
+
assert.equal(url.searchParams.get('agentName'), 'Isadora Buttonmoss');
|
|
55
|
+
assert.equal(url.searchParams.get('tab'), 'bookings');
|
|
56
|
+
assert.equal(url.searchParams.has('payment_intent_client_secret'), false);
|
|
57
|
+
assert.equal(url.searchParams.has('payment_intent'), false);
|
|
58
|
+
assert.equal(url.searchParams.has('reservationRef'), false);
|
|
59
|
+
assert.equal(url.searchParams.has('lastName'), false);
|
|
60
|
+
assert.equal(url.searchParams.has('embed_manage'), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('source attribution preserves safe custom partner query keys but strips token-like keys', () => {
|
|
64
|
+
const sanitized = sanitizeBookingSourceUrl(
|
|
65
|
+
'https://viaviamorainelake.com/partner/example?utm_source=partner&custom_partner_key=safe-value&accessToken=do-not-store',
|
|
66
|
+
);
|
|
67
|
+
assert.ok(sanitized);
|
|
68
|
+
|
|
69
|
+
const url = new URL(sanitized);
|
|
70
|
+
assert.equal(url.searchParams.get('utm_source'), 'partner');
|
|
71
|
+
assert.equal(url.searchParams.get('custom_partner_key'), 'safe-value');
|
|
72
|
+
assert.equal(url.searchParams.has('accessToken'), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
39
75
|
test('admin quote response display state cannot invalidate its request identity', () => {
|
|
40
76
|
const selectedAvailability = {
|
|
41
77
|
availabilityId: 'a_1',
|
|
@@ -69,6 +105,52 @@ test('admin quote response display state cannot invalidate its request identity'
|
|
|
69
105
|
assert.equal(keyAfterResponse, keyBeforeResponse);
|
|
70
106
|
});
|
|
71
107
|
|
|
108
|
+
test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
|
|
109
|
+
const selectedSummary = {
|
|
110
|
+
availabilityId: 'a_private',
|
|
111
|
+
productOptionId: 'po_private',
|
|
112
|
+
dateTime: '2026-07-22',
|
|
113
|
+
vacancies: 26,
|
|
114
|
+
currency: 'CAD',
|
|
115
|
+
isSummary: true,
|
|
116
|
+
};
|
|
117
|
+
const hydrated = {
|
|
118
|
+
...selectedSummary,
|
|
119
|
+
dateTime: '2026-07-22T06:00:00+00:00',
|
|
120
|
+
isSummary: false,
|
|
121
|
+
rates: [
|
|
122
|
+
{
|
|
123
|
+
rateId: 'RESOURCE',
|
|
124
|
+
category: 'RESOURCE',
|
|
125
|
+
available: 26,
|
|
126
|
+
price: 2099.99,
|
|
127
|
+
priceByCurrency: { CAD: 2099.99 },
|
|
128
|
+
appliedAdjustments: [
|
|
129
|
+
{
|
|
130
|
+
type: 'dynamic',
|
|
131
|
+
id: 'private-shuttle-busy-season-surcharge',
|
|
132
|
+
name: 'Private Shuttle Busy Season Surcharge',
|
|
133
|
+
changeByCurrency: { CAD: 400 },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const resolved = resolveHydratedPrivateShuttleAvailability(
|
|
141
|
+
selectedSummary,
|
|
142
|
+
[hydrated],
|
|
143
|
+
'po_private',
|
|
144
|
+
);
|
|
145
|
+
assert.equal(resolved, hydrated);
|
|
146
|
+
assert.equal(applyResourceAdjustments(1699.99, resolved, 'CAD'), 2099.99);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('private shuttle checkout detects a quote total that differs from the displayed total', () => {
|
|
150
|
+
assert.equal(privateShuttlePriceChanged(2247, 2681), true);
|
|
151
|
+
assert.equal(privateShuttlePriceChanged(2681, 2681.0), false);
|
|
152
|
+
});
|
|
153
|
+
|
|
72
154
|
test('routes customer change quote to paid checkout when amount is due', () => {
|
|
73
155
|
const decision = evaluateChangeBookingQuoteForCheckout({
|
|
74
156
|
quote: quote({
|