@ticketboothapp/booking 1.2.150 → 1.2.152
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/AdminChangeCheckoutDialogs.tsx +1 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +49 -3
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -11
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +1 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +1 -0
- package/src/components/booking/private-shuttle-availability.ts +35 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +13 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +19 -1
- package/src/components/booking/reservation-hold.ts +27 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +13 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +14 -1
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +6 -4
- package/src/lib/booking/source-metadata.ts +74 -2
- package/src/lib/booking-api.ts +40 -9
- package/src/providers/booking-dialog-provider.tsx +3 -2
- package/test/change-booking-helpers.test.ts +104 -0
package/package.json
CHANGED
|
@@ -57,6 +57,7 @@ export function AdminChangeCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
3
4
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
4
5
|
import type { Currency } from './CurrencySwitcher';
|
|
6
|
+
import {
|
|
7
|
+
formatReservationHoldTime,
|
|
8
|
+
reservationHoldSecondsRemaining,
|
|
9
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
10
|
+
} from './reservation-hold';
|
|
5
11
|
|
|
6
12
|
interface AdminPaymentChoiceModalProps {
|
|
7
13
|
open: boolean;
|
|
@@ -9,6 +15,7 @@ interface AdminPaymentChoiceModalProps {
|
|
|
9
15
|
currency: Currency;
|
|
10
16
|
loading: boolean;
|
|
11
17
|
error: string;
|
|
18
|
+
reservationExpiration?: string;
|
|
12
19
|
onPayNow: () => void;
|
|
13
20
|
onConfirmWithoutPayment: () => void;
|
|
14
21
|
onCancel: () => void;
|
|
@@ -27,14 +34,27 @@ export function AdminPaymentChoiceModal({
|
|
|
27
34
|
currency,
|
|
28
35
|
loading,
|
|
29
36
|
error,
|
|
37
|
+
reservationExpiration,
|
|
30
38
|
onPayNow,
|
|
31
39
|
onConfirmWithoutPayment,
|
|
32
40
|
onCancel,
|
|
33
41
|
description,
|
|
34
42
|
payNowLabel,
|
|
35
43
|
}: AdminPaymentChoiceModalProps) {
|
|
44
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open || !reservationExpiration) return;
|
|
48
|
+
setNowMs(Date.now());
|
|
49
|
+
const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
50
|
+
return () => window.clearInterval(interval);
|
|
51
|
+
}, [open, reservationExpiration]);
|
|
52
|
+
|
|
36
53
|
if (!open) return null;
|
|
37
54
|
|
|
55
|
+
const secondsRemaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
56
|
+
const expired = secondsRemaining !== null && secondsRemaining <= 0;
|
|
57
|
+
|
|
38
58
|
const modal = (
|
|
39
59
|
<div
|
|
40
60
|
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
|
|
@@ -68,6 +88,32 @@ export function AdminPaymentChoiceModal({
|
|
|
68
88
|
</div>
|
|
69
89
|
|
|
70
90
|
<div className="p-6 flex flex-col gap-3 flex-1 min-h-0">
|
|
91
|
+
{secondsRemaining !== null ? (
|
|
92
|
+
<div
|
|
93
|
+
className={`rounded-lg border p-3 text-sm ${
|
|
94
|
+
expired
|
|
95
|
+
? 'border-red-200 bg-red-50 text-red-800'
|
|
96
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
|
97
|
+
}`}
|
|
98
|
+
role={expired ? 'alert' : 'status'}
|
|
99
|
+
aria-live="polite"
|
|
100
|
+
>
|
|
101
|
+
{expired ? (
|
|
102
|
+
<>
|
|
103
|
+
<p className="font-semibold">Reservation hold expired</p>
|
|
104
|
+
<p className="mt-1">{RESERVATION_HOLD_EXPIRED_MESSAGE}</p>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<p className="font-semibold">
|
|
109
|
+
Reservation held for {formatReservationHoldTime(secondsRemaining)}
|
|
110
|
+
</p>
|
|
111
|
+
<p className="mt-1">Complete payment or confirm the booking before the hold expires.</p>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
71
117
|
{error ? (
|
|
72
118
|
<p className="text-sm text-red-600" role="alert">
|
|
73
119
|
{error}
|
|
@@ -77,7 +123,7 @@ export function AdminPaymentChoiceModal({
|
|
|
77
123
|
<button
|
|
78
124
|
type="button"
|
|
79
125
|
onClick={onPayNow}
|
|
80
|
-
disabled={loading}
|
|
126
|
+
disabled={loading || expired}
|
|
81
127
|
className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
82
128
|
>
|
|
83
129
|
{loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
|
|
@@ -85,7 +131,7 @@ export function AdminPaymentChoiceModal({
|
|
|
85
131
|
<button
|
|
86
132
|
type="button"
|
|
87
133
|
onClick={onConfirmWithoutPayment}
|
|
88
|
-
disabled={loading}
|
|
134
|
+
disabled={loading || expired}
|
|
89
135
|
className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
90
136
|
>
|
|
91
137
|
Confirm without payment
|
|
@@ -96,7 +142,7 @@ export function AdminPaymentChoiceModal({
|
|
|
96
142
|
disabled={loading}
|
|
97
143
|
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 font-medium disabled:opacity-50"
|
|
98
144
|
>
|
|
99
|
-
Cancel
|
|
145
|
+
{expired ? 'Close and restart checkout' : 'Cancel'}
|
|
100
146
|
</button>
|
|
101
147
|
</div>
|
|
102
148
|
</div>
|
|
@@ -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}
|
|
@@ -57,6 +57,7 @@ export function PrivateShuttleCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
description={
|
|
61
62
|
adminChoiceData?.isDepositPayment
|
|
62
63
|
? 'Pay the deposit now, or confirm without payment. The customer can pay the deposit or remaining balance from the Manage Booking page.'
|
|
@@ -57,6 +57,7 @@ export function StandardBookingCheckoutDialogs({
|
|
|
57
57
|
currency={currency}
|
|
58
58
|
loading={loading}
|
|
59
59
|
error={error}
|
|
60
|
+
reservationExpiration={adminChoiceData?.reservationExpiration}
|
|
60
61
|
onPayNow={onPayNow}
|
|
61
62
|
onConfirmWithoutPayment={onConfirmWithoutPayment}
|
|
62
63
|
onCancel={onAdminPaymentChoiceCancel}
|
|
@@ -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
|
}
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
type PrivateShuttleCheckoutModalData,
|
|
48
48
|
} from './private-shuttle-payment-choice-runner';
|
|
49
49
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
50
|
+
import {
|
|
51
|
+
reservationHoldHasExpired,
|
|
52
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
53
|
+
} from './reservation-hold';
|
|
50
54
|
|
|
51
55
|
export interface UsePrivateShuttleCheckoutControllerParams {
|
|
52
56
|
selectedOption: string;
|
|
@@ -676,6 +680,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
676
680
|
const handleConfirmWithoutPayment = async () => {
|
|
677
681
|
const choice = adminChoiceData;
|
|
678
682
|
if (!choice) return;
|
|
683
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
684
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
679
687
|
setLoading(true);
|
|
680
688
|
setError('');
|
|
681
689
|
try {
|
|
@@ -718,6 +726,10 @@ export function usePrivateShuttleCheckoutController({
|
|
|
718
726
|
const handlePayNow = () => {
|
|
719
727
|
const choice = adminChoiceData;
|
|
720
728
|
if (!choice) return;
|
|
729
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
730
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
721
733
|
setShowAdminPaymentChoice(false);
|
|
722
734
|
setCheckoutClientSecret(choice.clientSecret);
|
|
723
735
|
setCheckoutModalData(buildPrivateShuttlePayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -726,6 +738,7 @@ export function usePrivateShuttleCheckoutController({
|
|
|
726
738
|
};
|
|
727
739
|
|
|
728
740
|
const handleAdminPaymentChoiceCancel = () => {
|
|
741
|
+
cancelPendingReservation();
|
|
729
742
|
setShowAdminPaymentChoice(false);
|
|
730
743
|
setAdminChoiceData(null);
|
|
731
744
|
setError('');
|
|
@@ -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,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const RESERVATION_HOLD_EXPIRED_MESSAGE =
|
|
2
|
+
'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.';
|
|
3
|
+
|
|
4
|
+
export function reservationHoldSecondsRemaining(
|
|
5
|
+
reservationExpiration: string | undefined,
|
|
6
|
+
nowMs: number = Date.now(),
|
|
7
|
+
): number | null {
|
|
8
|
+
if (!reservationExpiration) return null;
|
|
9
|
+
const expirationMs = Date.parse(reservationExpiration);
|
|
10
|
+
if (!Number.isFinite(expirationMs)) return 0;
|
|
11
|
+
return Math.max(0, Math.ceil((expirationMs - nowMs) / 1000));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function reservationHoldHasExpired(
|
|
15
|
+
reservationExpiration: string | undefined,
|
|
16
|
+
nowMs: number = Date.now(),
|
|
17
|
+
): boolean {
|
|
18
|
+
const remaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
|
|
19
|
+
return remaining !== null && remaining <= 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatReservationHoldTime(seconds: number): string {
|
|
23
|
+
const safeSeconds = Math.max(0, Math.floor(seconds));
|
|
24
|
+
const minutes = Math.floor(safeSeconds / 60);
|
|
25
|
+
const remainder = safeSeconds % 60;
|
|
26
|
+
return `${minutes}:${String(remainder).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type StandardCheckoutModalData,
|
|
43
43
|
} from './standard-booking-payment-choice-runner';
|
|
44
44
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
45
|
+
import {
|
|
46
|
+
reservationHoldHasExpired,
|
|
47
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
48
|
+
} from './reservation-hold';
|
|
45
49
|
|
|
46
50
|
export interface UseStandardBookingCheckoutControllerParams {
|
|
47
51
|
selectedAvailability: Availability | null;
|
|
@@ -590,6 +594,10 @@ export function useStandardBookingCheckoutController({
|
|
|
590
594
|
const handleConfirmWithoutPayment = async () => {
|
|
591
595
|
const choice = adminChoiceData;
|
|
592
596
|
if (!choice) return;
|
|
597
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
598
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
593
601
|
setLoading(true);
|
|
594
602
|
setError('');
|
|
595
603
|
try {
|
|
@@ -634,6 +642,10 @@ export function useStandardBookingCheckoutController({
|
|
|
634
642
|
const handlePayNow = () => {
|
|
635
643
|
const choice = adminChoiceData;
|
|
636
644
|
if (!choice) return;
|
|
645
|
+
if (reservationHoldHasExpired(choice.reservationExpiration)) {
|
|
646
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
637
649
|
setShowAdminPaymentChoice(false);
|
|
638
650
|
setCheckoutClientSecret(choice.clientSecret);
|
|
639
651
|
setCheckoutModalData(buildStandardPayNowCheckoutModalData(choice, lastName.trim()));
|
|
@@ -642,6 +654,7 @@ export function useStandardBookingCheckoutController({
|
|
|
642
654
|
};
|
|
643
655
|
|
|
644
656
|
const handleAdminPaymentChoiceCancel = () => {
|
|
657
|
+
cancelPendingReservation();
|
|
645
658
|
setShowAdminPaymentChoice(false);
|
|
646
659
|
setAdminChoiceData(null);
|
|
647
660
|
setError('');
|
|
@@ -62,6 +62,10 @@ import {
|
|
|
62
62
|
confirmFreeAdminCustomerChange,
|
|
63
63
|
quoteAdminCustomerChangeForCheckout,
|
|
64
64
|
} from './admin-change-customer-quote-runner';
|
|
65
|
+
import {
|
|
66
|
+
reservationHoldHasExpired,
|
|
67
|
+
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
68
|
+
} from './reservation-hold';
|
|
65
69
|
|
|
66
70
|
type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
|
|
67
71
|
|
|
@@ -690,6 +694,10 @@ export function useAdminChangeCheckoutController({
|
|
|
690
694
|
|
|
691
695
|
const handleConfirmWithoutPayment = async () => {
|
|
692
696
|
if (!adminChoiceData) return;
|
|
697
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
698
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
693
701
|
setLoading(true);
|
|
694
702
|
setError('');
|
|
695
703
|
try {
|
|
@@ -771,6 +779,10 @@ export function useAdminChangeCheckoutController({
|
|
|
771
779
|
|
|
772
780
|
const handlePayNow = () => {
|
|
773
781
|
if (!adminChoiceData) return;
|
|
782
|
+
if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
|
|
783
|
+
setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
774
786
|
setShowAdminPaymentChoice(false);
|
|
775
787
|
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
776
788
|
setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
|
|
@@ -787,10 +799,11 @@ export function useAdminChangeCheckoutController({
|
|
|
787
799
|
}, []);
|
|
788
800
|
|
|
789
801
|
const handleAdminPaymentChoiceCancel = useCallback(() => {
|
|
802
|
+
cancelPendingReservation();
|
|
790
803
|
setShowAdminPaymentChoice(false);
|
|
791
804
|
setAdminChoiceData(null);
|
|
792
805
|
setError('');
|
|
793
|
-
}, [setError]);
|
|
806
|
+
}, [cancelPendingReservation, setError]);
|
|
794
807
|
|
|
795
808
|
return {
|
|
796
809
|
showCheckoutModal,
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -64,6 +68,9 @@ function getUserFacingMessage(endpoint: string): string {
|
|
|
64
68
|
return 'Unable to hold your booking right now. Please try again.';
|
|
65
69
|
case '/checkout/payment-intent':
|
|
66
70
|
return 'Unable to continue to payment right now. Please try again.';
|
|
71
|
+
case '/checkout/confirm-booking-without-payment':
|
|
72
|
+
case '/1/partner/confirm-booking-without-payment':
|
|
73
|
+
return 'Unable to confirm this booking right now. Please try again or restart checkout.';
|
|
67
74
|
default:
|
|
68
75
|
return 'Something went wrong while loading booking details. Please try again.';
|
|
69
76
|
}
|
|
@@ -105,7 +112,7 @@ function reportBookingClientTelemetryEvent(
|
|
|
105
112
|
traceparent,
|
|
106
113
|
...(traceId ? { traceId } : {}),
|
|
107
114
|
apiBase: API_BASE,
|
|
108
|
-
pageUrl: window.location.href,
|
|
115
|
+
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
109
116
|
userAgent: window.navigator.userAgent,
|
|
110
117
|
online: window.navigator.onLine,
|
|
111
118
|
occurredAt: new Date().toISOString(),
|
|
@@ -182,7 +189,11 @@ function createUserError(
|
|
|
182
189
|
bookingApiErrorCode?: string
|
|
183
190
|
): BookingClientError {
|
|
184
191
|
const supportCode = buildSupportCode(endpoint, errorClass);
|
|
185
|
-
const userMessage =
|
|
192
|
+
const userMessage = bookingApiErrorCode === 'RESERVATION_EXPIRED'
|
|
193
|
+
? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
|
|
194
|
+
: bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
|
|
195
|
+
? 'This reservation has already been completed or closed. Refresh before trying again.'
|
|
196
|
+
: `${getUserFacingMessage(endpoint)} (${supportCode})`;
|
|
186
197
|
const error = new Error(userMessage) as BookingClientError;
|
|
187
198
|
error.debugMessage = debugMessage;
|
|
188
199
|
if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
|
|
@@ -424,7 +435,7 @@ function getBrowserDiagnostics(): Record<string, number | string | boolean> {
|
|
|
424
435
|
siteOrigin: window.location.origin,
|
|
425
436
|
siteProtocol: window.location.protocol,
|
|
426
437
|
isSecureContext: window.isSecureContext,
|
|
427
|
-
referrer: document.referrer
|
|
438
|
+
referrer: sanitizeBookingSourceUrl(document.referrer) ?? '',
|
|
428
439
|
};
|
|
429
440
|
}
|
|
430
441
|
|
|
@@ -568,7 +579,7 @@ async function collectNetworkFailureProbeResults(
|
|
|
568
579
|
correlationId,
|
|
569
580
|
traceparent,
|
|
570
581
|
apiBase: API_BASE,
|
|
571
|
-
pageUrl: window.location.href,
|
|
582
|
+
pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
|
|
572
583
|
userAgent: window.navigator.userAgent,
|
|
573
584
|
online: window.navigator.onLine,
|
|
574
585
|
occurredAt: new Date().toISOString(),
|
|
@@ -2576,7 +2587,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2576
2587
|
httpStatus: res.status,
|
|
2577
2588
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2578
2589
|
});
|
|
2579
|
-
throw createUserError(
|
|
2590
|
+
throw createUserError(
|
|
2591
|
+
endpoint,
|
|
2592
|
+
'HTTP',
|
|
2593
|
+
debugMessage,
|
|
2594
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2595
|
+
);
|
|
2580
2596
|
}
|
|
2581
2597
|
const data = await parseJsonSafely(res);
|
|
2582
2598
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2587,7 +2603,12 @@ export async function confirmBookingWithoutPayment(
|
|
|
2587
2603
|
message: appError,
|
|
2588
2604
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2589
2605
|
});
|
|
2590
|
-
throw createUserError(
|
|
2606
|
+
throw createUserError(
|
|
2607
|
+
endpoint,
|
|
2608
|
+
'APP_ERROR_200',
|
|
2609
|
+
appError,
|
|
2610
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2611
|
+
);
|
|
2591
2612
|
}
|
|
2592
2613
|
logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
|
|
2593
2614
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -2632,7 +2653,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2632
2653
|
httpStatus: res.status,
|
|
2633
2654
|
errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2634
2655
|
});
|
|
2635
|
-
throw createUserError(
|
|
2656
|
+
throw createUserError(
|
|
2657
|
+
endpoint,
|
|
2658
|
+
'HTTP',
|
|
2659
|
+
debugMessage,
|
|
2660
|
+
isApiErrorPayload(err) ? err.errorCode : undefined,
|
|
2661
|
+
);
|
|
2636
2662
|
}
|
|
2637
2663
|
const data = await parseJsonSafely(res);
|
|
2638
2664
|
const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
|
|
@@ -2643,7 +2669,12 @@ export async function confirmPartnerBookingWithoutPayment(
|
|
|
2643
2669
|
message: appError,
|
|
2644
2670
|
errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2645
2671
|
});
|
|
2646
|
-
throw createUserError(
|
|
2672
|
+
throw createUserError(
|
|
2673
|
+
endpoint,
|
|
2674
|
+
'APP_ERROR_200',
|
|
2675
|
+
appError,
|
|
2676
|
+
isApiErrorPayload(data) ? data.errorCode : undefined,
|
|
2677
|
+
);
|
|
2647
2678
|
}
|
|
2648
2679
|
logBookingSourceDebug('response', endpoint, data);
|
|
2649
2680
|
return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
|
|
@@ -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,17 @@ 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';
|
|
17
|
+
import {
|
|
18
|
+
formatReservationHoldTime,
|
|
19
|
+
reservationHoldHasExpired,
|
|
20
|
+
reservationHoldSecondsRemaining,
|
|
21
|
+
} from '../src/components/booking/reservation-hold';
|
|
11
22
|
|
|
12
23
|
function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
|
|
13
24
|
return {
|
|
@@ -36,6 +47,53 @@ function test(name: string, fn: () => void): void {
|
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
test('admin payment choice countdown expires exactly at the reservation deadline', () => {
|
|
51
|
+
const expiration = '2026-07-16T15:46:44+00:00';
|
|
52
|
+
assert.equal(
|
|
53
|
+
reservationHoldSecondsRemaining(expiration, Date.parse('2026-07-16T15:46:34Z')),
|
|
54
|
+
10,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:43.999Z')), false);
|
|
57
|
+
assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:44Z')), true);
|
|
58
|
+
assert.equal(formatReservationHoldTime(604), '10:04');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('malformed reservation expiration fails closed', () => {
|
|
62
|
+
assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
|
|
63
|
+
assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
|
|
64
|
+
assert.equal(reservationHoldHasExpired(undefined, Date.now()), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
|
|
68
|
+
const sanitized = sanitizeBookingSourceUrl(
|
|
69
|
+
'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',
|
|
70
|
+
);
|
|
71
|
+
assert.ok(sanitized);
|
|
72
|
+
|
|
73
|
+
const url = new URL(sanitized);
|
|
74
|
+
assert.equal(url.searchParams.get('partnerId'), 'par_safe');
|
|
75
|
+
assert.equal(url.searchParams.get('agentId'), 'agent_safe');
|
|
76
|
+
assert.equal(url.searchParams.get('agentName'), 'Isadora Buttonmoss');
|
|
77
|
+
assert.equal(url.searchParams.get('tab'), 'bookings');
|
|
78
|
+
assert.equal(url.searchParams.has('payment_intent_client_secret'), false);
|
|
79
|
+
assert.equal(url.searchParams.has('payment_intent'), false);
|
|
80
|
+
assert.equal(url.searchParams.has('reservationRef'), false);
|
|
81
|
+
assert.equal(url.searchParams.has('lastName'), false);
|
|
82
|
+
assert.equal(url.searchParams.has('embed_manage'), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('source attribution preserves safe custom partner query keys but strips token-like keys', () => {
|
|
86
|
+
const sanitized = sanitizeBookingSourceUrl(
|
|
87
|
+
'https://viaviamorainelake.com/partner/example?utm_source=partner&custom_partner_key=safe-value&accessToken=do-not-store',
|
|
88
|
+
);
|
|
89
|
+
assert.ok(sanitized);
|
|
90
|
+
|
|
91
|
+
const url = new URL(sanitized);
|
|
92
|
+
assert.equal(url.searchParams.get('utm_source'), 'partner');
|
|
93
|
+
assert.equal(url.searchParams.get('custom_partner_key'), 'safe-value');
|
|
94
|
+
assert.equal(url.searchParams.has('accessToken'), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
39
97
|
test('admin quote response display state cannot invalidate its request identity', () => {
|
|
40
98
|
const selectedAvailability = {
|
|
41
99
|
availabilityId: 'a_1',
|
|
@@ -69,6 +127,52 @@ test('admin quote response display state cannot invalidate its request identity'
|
|
|
69
127
|
assert.equal(keyAfterResponse, keyBeforeResponse);
|
|
70
128
|
});
|
|
71
129
|
|
|
130
|
+
test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
|
|
131
|
+
const selectedSummary = {
|
|
132
|
+
availabilityId: 'a_private',
|
|
133
|
+
productOptionId: 'po_private',
|
|
134
|
+
dateTime: '2026-07-22',
|
|
135
|
+
vacancies: 26,
|
|
136
|
+
currency: 'CAD',
|
|
137
|
+
isSummary: true,
|
|
138
|
+
};
|
|
139
|
+
const hydrated = {
|
|
140
|
+
...selectedSummary,
|
|
141
|
+
dateTime: '2026-07-22T06:00:00+00:00',
|
|
142
|
+
isSummary: false,
|
|
143
|
+
rates: [
|
|
144
|
+
{
|
|
145
|
+
rateId: 'RESOURCE',
|
|
146
|
+
category: 'RESOURCE',
|
|
147
|
+
available: 26,
|
|
148
|
+
price: 2099.99,
|
|
149
|
+
priceByCurrency: { CAD: 2099.99 },
|
|
150
|
+
appliedAdjustments: [
|
|
151
|
+
{
|
|
152
|
+
type: 'dynamic',
|
|
153
|
+
id: 'private-shuttle-busy-season-surcharge',
|
|
154
|
+
name: 'Private Shuttle Busy Season Surcharge',
|
|
155
|
+
changeByCurrency: { CAD: 400 },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const resolved = resolveHydratedPrivateShuttleAvailability(
|
|
163
|
+
selectedSummary,
|
|
164
|
+
[hydrated],
|
|
165
|
+
'po_private',
|
|
166
|
+
);
|
|
167
|
+
assert.equal(resolved, hydrated);
|
|
168
|
+
assert.equal(applyResourceAdjustments(1699.99, resolved, 'CAD'), 2099.99);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('private shuttle checkout detects a quote total that differs from the displayed total', () => {
|
|
172
|
+
assert.equal(privateShuttlePriceChanged(2247, 2681), true);
|
|
173
|
+
assert.equal(privateShuttlePriceChanged(2681, 2681.0), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
72
176
|
test('routes customer change quote to paid checkout when amount is due', () => {
|
|
73
177
|
const decision = evaluateChangeBookingQuoteForCheckout({
|
|
74
178
|
quote: quote({
|