@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.150",
3
+ "version": "1.2.152",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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 = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
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
- selectedAvailability?.suggestedStartTimes,
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 (!selectedAvailability) return;
473
- const vacancies = selectedAvailability.vacancies || 0;
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
- }, [selectedAvailability]);
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 = selectedAvailability?.vacancies || 0;
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 && selectedAvailability && (
806
+ {selectedOption && effectiveSelectedAvailability && (
797
807
  <PrivateShuttlePassengerSection
798
808
  passengerCount={passengerCount}
799
809
  resourceCount={resourceCount}
800
810
  billableResourceCount={billableResourceCount}
801
- maxVacancies={selectedAvailability.vacancies || 0}
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 && selectedAvailability && passengerCount > 0 && (
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 { findMergedPrivateShuttleAvailability } from './private-shuttle-availability';
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 **path** changes. Query-only updates on the
12
- * same path re-read on the next full navigation / remount (metadata reads live `window.location`).
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 currentUrl = new URL(window.location.href);
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 || undefined,
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,
@@ -17,7 +17,11 @@ import {
17
17
  normalizeBookingProductId,
18
18
  } from './booking/normalize-booking-product-id';
19
19
  import { ENV } from './env';
20
- import { DEFAULT_BOOKING_SOURCE, type BookingSourceMetadata } from './booking/source-metadata';
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 = `${getUserFacingMessage(endpoint)} (${supportCode})`;
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(endpoint, 'HTTP', debugMessage);
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(endpoint, 'APP_ERROR_200', appError);
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(endpoint, 'HTTP', debugMessage);
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(endpoint, 'APP_ERROR_200', appError);
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 || null,
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({