@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.149",
3
+ "version": "1.2.151",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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}
@@ -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 { 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,
@@ -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
  }
@@ -199,7 +199,11 @@ export function mapQuoteLineItemsToPriceSummaryLines(
199
199
  ) {
200
200
  const category =
201
201
  type === 'TICKET'
202
- ? (label.split(/\s+/)[0]?.replace(/[^a-zA-Z]/g, '') || 'TICKET').toUpperCase()
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 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
 
@@ -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 || 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,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({