@ticketboothapp/booking 1.2.113 → 1.2.114

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.113",
3
+ "version": "1.2.114",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -19,6 +19,7 @@
19
19
  "./providers/booking-dialog-provider": "./src/providers/booking-dialog-provider.tsx",
20
20
  "./hooks/useBookingSourceMetadataFromLocation": "./src/hooks/useBookingSourceMetadataFromLocation.ts",
21
21
  "./hooks/useIsBookingLaunchLive": "./src/hooks/useIsBookingLaunchLive.ts",
22
+ "./hooks/usePlacesAutocompleteSearch": "./src/hooks/usePlacesAutocompleteSearch.ts",
22
23
  "./viavia/catalog": "./src/constants/products.ts",
23
24
  "./viavia/images": "./src/constants/images.ts",
24
25
  "./viavia/product-descriptions": "./src/lib/product-descriptions.ts",
@@ -2325,8 +2325,9 @@ export function NewBookingFlow({
2325
2325
  /** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
2326
2326
  const promoDiscountParamsRef = useRef({
2327
2327
  selectedAvailability: null as Availability | null,
2328
- ticketLineItems: [] as Array<{ category: string; qty: number }>,
2328
+ ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
2329
2329
  effectiveSubtotal: 0,
2330
+ displayedProductFeeTotal: 0,
2330
2331
  appliedPromoCode: null as string | null,
2331
2332
  });
2332
2333
 
@@ -2345,6 +2346,11 @@ export function NewBookingFlow({
2345
2346
  selectedAvailability.availabilityId ?? '',
2346
2347
  currency,
2347
2348
  quantitiesSignature,
2349
+ ticketLineItems
2350
+ .map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
2351
+ .sort()
2352
+ .join('|'),
2353
+ String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
2348
2354
  String(Math.round(effectiveSubtotal * 100)),
2349
2355
  ].join('::');
2350
2356
  }, [
@@ -2357,13 +2363,16 @@ export function NewBookingFlow({
2357
2363
  product.productId,
2358
2364
  currency,
2359
2365
  quantitiesSignature,
2366
+ ticketLineItems,
2367
+ feeLineItems,
2360
2368
  effectiveSubtotal,
2361
2369
  ]);
2362
2370
 
2363
2371
  promoDiscountParamsRef.current = {
2364
2372
  selectedAvailability,
2365
- ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
2373
+ ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty, itemTotal: l.itemTotal })),
2366
2374
  effectiveSubtotal,
2375
+ displayedProductFeeTotal: feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0),
2367
2376
  appliedPromoCode,
2368
2377
  };
2369
2378
 
@@ -2383,11 +2392,12 @@ export function NewBookingFlow({
2383
2392
  selectedAvailability: sel,
2384
2393
  ticketLineItems: lines,
2385
2394
  effectiveSubtotal: sub,
2395
+ displayedProductFeeTotal,
2386
2396
  appliedPromoCode: code,
2387
2397
  } = promoDiscountParamsRef.current;
2388
2398
  if (!code || !sel) return;
2389
2399
  const companyId = product.companyId ?? env.COMPANY_ID;
2390
- const optionId = sel.productOptionId;
2400
+ const optionId = getAvailabilityOptionId(sel);
2391
2401
  if (!companyId || !optionId) return;
2392
2402
  const items = lines.map((l) => ({ category: l.category, qty: l.qty }));
2393
2403
  if (items.length === 0) return;
@@ -2401,7 +2411,13 @@ export function NewBookingFlow({
2401
2411
  currency,
2402
2412
  items,
2403
2413
  sel.dateTime,
2404
- sub
2414
+ sub,
2415
+ {
2416
+ availabilityCount: sel.vacancies,
2417
+ totalCapacity: sel.totalCapacity,
2418
+ displayedTicketLines: lines,
2419
+ displayedProductFeeTotal,
2420
+ }
2405
2421
  )
2406
2422
  .then((res) => {
2407
2423
  if (cancelled) return;
@@ -2762,7 +2778,7 @@ export function NewBookingFlow({
2762
2778
  setPromoCodeError('');
2763
2779
  setPromoCodeValidating(true);
2764
2780
  try {
2765
- const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
2781
+ const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount, selectedAvailability.dateTime);
2766
2782
  if (result.valid) {
2767
2783
  promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
2768
2784
  setAppliedPromoCode(code);
@@ -2797,29 +2813,33 @@ export function NewBookingFlow({
2797
2813
  }
2798
2814
  }, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t, selectedAvailabilityKey, selectedAvailability, totalQuantity]);
2799
2815
 
2800
- // When user selects a time with ongoing discount and has a promo applied, re-validate and clear if promo can't be stacked
2816
+ // When user changes selected booking context, re-validate the applied promo against the new date/deal context.
2801
2817
  useEffect(() => {
2802
- if (!appliedPromoCode || !hasOngoingDiscount) return;
2818
+ if (!appliedPromoCode || !selectedAvailability) return;
2803
2819
  // Only run this guard when user moved away from the selection where promo was applied.
2804
2820
  // On the same selection, "deal" adjustments can be promo-driven and would self-clear incorrectly.
2805
2821
  if (promoAppliedSelectionKeyRef.current === selectedAvailabilityKey) {
2806
2822
  return;
2807
2823
  }
2808
2824
  let cancelled = false;
2809
- validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, true).then((result) => {
2825
+ validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, hasOngoingDiscount, selectedAvailability.dateTime).then((result) => {
2810
2826
  if (cancelled) return;
2811
- if (!result.valid && result.error === 'Promo codes cannot be stacked with deals') {
2827
+ if (!result.valid) {
2812
2828
  promoAppliedSelectionKeyRef.current = null;
2813
2829
  setAppliedPromoCode(null);
2814
2830
  setPromoCodeInput(appliedPromoCode);
2815
- setPromoCodeError(t('booking.promoCodesCannotStackWithDiscounts') || result.error);
2831
+ const errorMsg =
2832
+ result.error === 'Promo codes cannot be stacked with deals'
2833
+ ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
2834
+ : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
2835
+ setPromoCodeError(errorMsg);
2816
2836
  setForcedCancellationPolicy(null);
2817
2837
  setCancellationPolicyId(null);
2818
2838
  fetchedRangesRef.current = [];
2819
2839
  }
2820
2840
  });
2821
2841
  return () => { cancelled = true; };
2822
- }, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey]);
2842
+ }, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey, selectedAvailability]);
2823
2843
 
2824
2844
  // Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
2825
2845
  const handleApplyPromoRef = useRef(handleApplyPromo);
@@ -14,7 +14,7 @@ import styles from './PickupLocationDialog.module.css';
14
14
 
15
15
  const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
16
16
  const SAMSON_MALL_PARKING_DISCLAIMER =
17
- 'No public parking at this pickup location. You must be staying at a nearby Lake Louise hotel and walk to Samson Mall for pickup.';
17
+ 'No public parking at this pickup location. This pickup location is meant to serve guests staying at the HI Lake Louise Alpine Hostel, Lake Louise Inn, or Lake Louise Campground and can walk to Samson Mall for pickup due to parking restrictions in the town of Lake Louise.';
18
18
 
19
19
  function findProductWithPickupLocations(
20
20
  products: Product[],
@@ -14,10 +14,10 @@ import {
14
14
  isExactMatch,
15
15
  } from '../../lib/booking/location-calculations';
16
16
  import {
17
- getAutocompleteSuggestions,
18
17
  getPlaceDetails,
19
18
  type AutocompleteSuggestion,
20
19
  } from '../../lib/booking/places-api';
20
+ import { usePlacesAutocompleteSearch } from '../../hooks/usePlacesAutocompleteSearch';
21
21
  import {
22
22
  createDistanceMarkerIcon,
23
23
  createPinMarkerIcon,
@@ -79,7 +79,7 @@ const SEARCHED_LOCATION_ZOOM = 14;
79
79
  const MAX_ZOOM = 15;
80
80
  const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
81
81
  const SAMSON_MALL_PARKING_DISCLAIMER =
82
- 'No public parking at this pickup location. You must be staying at a nearby Lake Louise hotel and walk to Samson Mall for pickup.';
82
+ 'No public parking at this pickup location. This pickup location is meant to serve guests staying at the HI Lake Louise Alpine Hostel, Lake Louise Inn, or Lake Louise Campground and can walk to Samson Mall for pickup due to parking restrictions in the town of Lake Louise.';
83
83
 
84
84
  // Filter definitions
85
85
  const FILTERS = [
@@ -250,11 +250,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
250
250
  const [isGeocoding, setIsGeocoding] = useState(false);
251
251
  const [selectedMarker, setSelectedMarker] = useState<string | null>(null);
252
252
  const [hoveredMarker, setHoveredMarker] = useState<string | null>(null);
253
- const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<AutocompleteSuggestion[]>([]);
254
253
  const [exactMatchLocationId, setExactMatchLocationId] = useState<string | null>(null);
255
254
  const [cityFilter, setCityFilter] = useState<string | null>(null);
256
255
  const [selectedFilters, setSelectedFilters] = useState<Set<string>>(new Set());
257
- const [showSuggestions, setShowSuggestions] = useState(false);
258
256
  const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
259
257
  const [showSkipWarning, setShowSkipWarning] = useState(false);
260
258
  const [dontKnowFilterActive, setDontKnowFilterActive] = useState(false);
@@ -286,6 +284,20 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
286
284
  const { googleMapsApiKey: keyFromContext } = useBookingApp();
287
285
  const keyFromWindow = typeof window !== 'undefined' ? (window as unknown as { __TICKETBOOTH_GOOGLE_MAPS_API_KEY__?: string }).__TICKETBOOTH_GOOGLE_MAPS_API_KEY__ : undefined;
288
286
  const googleMapsApiKey = keyFromContext ?? keyFromWindow ?? env.GOOGLE_MAPS_API_KEY;
287
+ const {
288
+ suggestions: autocompleteSuggestions,
289
+ showSuggestions,
290
+ setShowSuggestions,
291
+ hideSuggestions,
292
+ clearSuggestions,
293
+ queueSuggestionsFetch,
294
+ consumeSessionToken,
295
+ endSession,
296
+ cancelPendingFetch,
297
+ } = usePlacesAutocompleteSearch({
298
+ apiKey: googleMapsApiKey,
299
+ locationBias: LOCATION_BIAS,
300
+ });
289
301
 
290
302
  // ============ Google Maps API Loading ============
291
303
  const { isLoaded, loadError } = useJsApiLoader({
@@ -391,14 +403,13 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
391
403
  setSearchedLocation(null);
392
404
  setIsValidLocation(null);
393
405
  setNearbyLocations([]);
394
- setShowSuggestions(false);
395
- setAutocompleteSuggestions([]);
406
+ clearSuggestions();
396
407
  setSelectedMarker(null);
397
408
  setExactMatchLocationId(null);
398
409
  setCityFilter(null);
399
410
  setHoveredMarker(null);
400
411
  }
401
- }, [isSkipped]);
412
+ }, [clearSuggestions, isSkipped]);
402
413
 
403
414
  // Clear search state when a location is selected from the list (parent updates selectedLocationId/selectedCustomAddress)
404
415
  // Only run when selection changes - NOT when user types (searchInput), otherwise typing gets erased
@@ -408,10 +419,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
408
419
  setSearchedLocation(null);
409
420
  setIsValidLocation(null);
410
421
  setNearbyLocations([]);
411
- setShowSuggestions(false);
412
- setAutocompleteSuggestions([]);
422
+ clearSuggestions();
413
423
  }
414
- }, [selectedLocationId, selectedCustomAddress]);
424
+ }, [clearSuggestions, selectedLocationId, selectedCustomAddress]);
415
425
 
416
426
  // Cleanup timeouts on unmount
417
427
  useEffect(() => {
@@ -427,32 +437,15 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
427
437
 
428
438
  // ============ Event Handlers ============
429
439
 
430
- // Handle autocomplete suggestions as user types
431
- const handleSearchInputChange = async (value: string) => {
440
+ const handleSearchInputChange = (value: string) => {
432
441
  setSearchInput(value);
433
-
434
- if (!value.trim() || !googleMapsApiKey) {
435
- setAutocompleteSuggestions([]);
436
- setShowSuggestions(false);
437
- return;
438
- }
439
-
440
- try {
441
- const suggestions = await getAutocompleteSuggestions(value, googleMapsApiKey, LOCATION_BIAS);
442
- // Only update if the input value hasn't changed (avoid race conditions)
443
- setAutocompleteSuggestions(suggestions);
444
- setShowSuggestions(suggestions.length > 0);
445
- } catch (error) {
446
- console.error('Error fetching autocomplete suggestions:', error);
447
- setAutocompleteSuggestions([]);
448
- setShowSuggestions(false);
449
- }
442
+ queueSuggestionsFetch(value);
450
443
  };
451
444
 
452
445
  const handleSuggestionSelect = async (suggestion: AutocompleteSuggestion) => {
446
+ cancelPendingFetch();
453
447
  setSearchInput(suggestion.description);
454
- setShowSuggestions(false);
455
- setAutocompleteSuggestions([]);
448
+ hideSuggestions();
456
449
  setExactMatchLocationId(null);
457
450
  setCityFilter(null);
458
451
 
@@ -475,15 +468,17 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
475
468
  setExactMatchLocationId(matchingPickupLocation.id);
476
469
  setCityFilter(null); // Clear city filter if it was set
477
470
  setSearchedLocation(null); // Don't show searched location pin
471
+ endSession();
478
472
  return;
479
473
  }
480
474
  }
481
475
 
482
476
  // Case 2 & 3: Get place details to determine if it's a city or specific location
483
477
  setIsGeocoding(true);
478
+ const sessionToken = consumeSessionToken();
484
479
  try {
485
480
  // Get place details including types to determine if it's a city
486
- const placeDetails = await getPlaceDetails(suggestion.placeId, googleMapsApiKey);
481
+ const placeDetails = await getPlaceDetails(suggestion.placeId, googleMapsApiKey, sessionToken);
487
482
 
488
483
  if (placeDetails) {
489
484
  const { lat, lng, types } = placeDetails;
@@ -513,7 +508,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
513
508
  }
514
509
  }
515
510
 
516
- // Case 3: It's a specific location - geocode and show 3 closest
511
+ // Case 3: It's a specific location - show 3 closest
517
512
  setExactMatchLocationId(null);
518
513
  setCityFilter(null);
519
514
  setSearchedLocation({
@@ -569,13 +564,15 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
569
564
  setSearchedLocation(null);
570
565
  setIsValidLocation(null);
571
566
  setNearbyLocations([]);
572
- setShowSuggestions(false);
567
+ clearSuggestions();
573
568
  setExactMatchLocationId(null);
574
569
  setCityFilter(null);
575
570
  return;
576
571
  }
577
572
 
578
- setShowSuggestions(false);
573
+ cancelPendingFetch();
574
+ hideSuggestions();
575
+ endSession();
579
576
  setExactMatchLocationId(null);
580
577
  setCityFilter(null);
581
578
 
@@ -667,7 +664,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
667
664
  setSearchedLocation(null);
668
665
  setIsValidLocation(null);
669
666
  setNearbyLocations([]);
670
- setShowSuggestions(false);
667
+ clearSuggestions();
671
668
  setSelectedMarker(null);
672
669
  setHoveredMarker(null);
673
670
  };
@@ -710,8 +707,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
710
707
  setSearchedLocation(null);
711
708
  setIsValidLocation(null);
712
709
  setNearbyLocations([]);
713
- setShowSuggestions(false);
714
- setAutocompleteSuggestions([]);
710
+ clearSuggestions();
715
711
  setSelectedMarker(null);
716
712
  setHoveredMarker(null);
717
713
 
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import {
5
+ createPlacesSessionToken,
6
+ getAutocompleteSuggestions,
7
+ type AutocompleteSuggestion,
8
+ type PlacesLocationBias,
9
+ } from '../lib/booking/places-api';
10
+
11
+ const DEFAULT_DEBOUNCE_MS = 300;
12
+
13
+ interface UsePlacesAutocompleteSearchOptions {
14
+ apiKey: string;
15
+ locationBias?: PlacesLocationBias;
16
+ debounceMs?: number;
17
+ }
18
+
19
+ export function usePlacesAutocompleteSearch({
20
+ apiKey,
21
+ locationBias,
22
+ debounceMs = DEFAULT_DEBOUNCE_MS,
23
+ }: UsePlacesAutocompleteSearchOptions) {
24
+ const [suggestions, setSuggestions] = useState<AutocompleteSuggestion[]>([]);
25
+ const [showSuggestions, setShowSuggestions] = useState(false);
26
+ const sessionTokenRef = useRef<string | null>(null);
27
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+ const requestIdRef = useRef(0);
29
+
30
+ const cancelPendingFetch = useCallback(() => {
31
+ if (debounceRef.current) {
32
+ clearTimeout(debounceRef.current);
33
+ debounceRef.current = null;
34
+ }
35
+ requestIdRef.current += 1;
36
+ }, []);
37
+
38
+ const hideSuggestions = useCallback(() => {
39
+ setSuggestions([]);
40
+ setShowSuggestions(false);
41
+ }, []);
42
+
43
+ const endSession = useCallback(() => {
44
+ sessionTokenRef.current = null;
45
+ }, []);
46
+
47
+ const clearSuggestions = useCallback(() => {
48
+ cancelPendingFetch();
49
+ hideSuggestions();
50
+ endSession();
51
+ }, [cancelPendingFetch, hideSuggestions, endSession]);
52
+
53
+ const consumeSessionToken = useCallback((): string | undefined => {
54
+ const token = sessionTokenRef.current;
55
+ sessionTokenRef.current = null;
56
+ return token ?? undefined;
57
+ }, []);
58
+
59
+ const ensureSessionToken = useCallback(() => {
60
+ if (!sessionTokenRef.current) {
61
+ sessionTokenRef.current = createPlacesSessionToken();
62
+ }
63
+ return sessionTokenRef.current;
64
+ }, []);
65
+
66
+ const fetchSuggestions = useCallback(
67
+ async (input: string) => {
68
+ const requestId = ++requestIdRef.current;
69
+
70
+ try {
71
+ const sessionToken = ensureSessionToken();
72
+ const nextSuggestions = await getAutocompleteSuggestions(
73
+ input,
74
+ apiKey,
75
+ locationBias,
76
+ sessionToken
77
+ );
78
+
79
+ if (requestId !== requestIdRef.current) {
80
+ return;
81
+ }
82
+
83
+ setSuggestions(nextSuggestions);
84
+ setShowSuggestions(nextSuggestions.length > 0);
85
+ } catch (error) {
86
+ if (requestId !== requestIdRef.current) {
87
+ return;
88
+ }
89
+
90
+ console.error('Error fetching autocomplete suggestions:', error);
91
+ setSuggestions([]);
92
+ setShowSuggestions(false);
93
+ }
94
+ },
95
+ [apiKey, ensureSessionToken, locationBias]
96
+ );
97
+
98
+ const queueSuggestionsFetch = useCallback(
99
+ (input: string) => {
100
+ cancelPendingFetch();
101
+
102
+ if (!input.trim() || !apiKey) {
103
+ hideSuggestions();
104
+ endSession();
105
+ return;
106
+ }
107
+
108
+ debounceRef.current = setTimeout(() => {
109
+ debounceRef.current = null;
110
+ void fetchSuggestions(input);
111
+ }, debounceMs);
112
+ },
113
+ [apiKey, cancelPendingFetch, debounceMs, endSession, fetchSuggestions, hideSuggestions]
114
+ );
115
+
116
+ useEffect(() => {
117
+ return () => {
118
+ cancelPendingFetch();
119
+ };
120
+ }, [cancelPendingFetch]);
121
+
122
+ return {
123
+ suggestions,
124
+ showSuggestions,
125
+ setShowSuggestions,
126
+ hideSuggestions,
127
+ clearSuggestions,
128
+ queueSuggestionsFetch,
129
+ consumeSessionToken,
130
+ endSession,
131
+ cancelPendingFetch,
132
+ };
133
+ }
@@ -9,6 +9,12 @@ export interface AutocompleteSuggestion {
9
9
  description: string;
10
10
  }
11
11
 
12
+ export interface PlacesLocationBias {
13
+ latitude: number;
14
+ longitude: number;
15
+ radius: number;
16
+ }
17
+
12
18
  // Internal API response types
13
19
  interface PlacePrediction {
14
20
  placeId: string;
@@ -25,13 +31,22 @@ interface AutocompleteSuggestionResponse {
25
31
 
26
32
  const GOOGLE_PLACES_API_BASE = 'https://places.googleapis.com/v1';
27
33
 
34
+ /** Session tokens group autocomplete keystrokes + place selection into one billing session. */
35
+ export function createPlacesSessionToken(): string {
36
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
37
+ return crypto.randomUUID();
38
+ }
39
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
40
+ }
41
+
28
42
  /**
29
43
  * Get autocomplete suggestions using Places API (New)
30
44
  */
31
45
  export async function getAutocompleteSuggestions(
32
46
  input: string,
33
47
  apiKey: string,
34
- locationBias?: { latitude: number; longitude: number; radius: number }
48
+ locationBias?: PlacesLocationBias,
49
+ sessionToken?: string
35
50
  ): Promise<AutocompleteSuggestion[]> {
36
51
  if (!input.trim() || !apiKey) {
37
52
  return [];
@@ -49,6 +64,7 @@ export async function getAutocompleteSuggestions(
49
64
  body: JSON.stringify({
50
65
  input,
51
66
  includedRegionCodes: ['ca'], // Restrict to Canada
67
+ ...(sessionToken && { sessionToken }),
52
68
  ...(locationBias && {
53
69
  locationBias: {
54
70
  circle: {
@@ -75,7 +91,7 @@ export async function getAutocompleteSuggestions(
75
91
 
76
92
  if (data.suggestions && Array.isArray(data.suggestions)) {
77
93
  return data.suggestions
78
- .filter((s): s is AutocompleteSuggestionResponse & { placePrediction: PlacePrediction } =>
94
+ .filter((s): s is AutocompleteSuggestionResponse & { placePrediction: PlacePrediction } =>
79
95
  !!s.placePrediction
80
96
  )
81
97
  .map((s) => {
@@ -104,10 +120,12 @@ export async function getAutocompleteSuggestions(
104
120
  */
105
121
  export async function getPlaceDetails(
106
122
  placeId: string,
107
- apiKey: string
123
+ apiKey: string,
124
+ sessionToken?: string
108
125
  ): Promise<{ lat: number; lng: number; types: string[] } | null> {
109
126
  try {
110
- const response = await fetch(`${GOOGLE_PLACES_API_BASE}/places/${placeId}`, {
127
+ const sessionQuery = sessionToken ? `?sessionToken=${encodeURIComponent(sessionToken)}` : '';
128
+ const response = await fetch(`${GOOGLE_PLACES_API_BASE}/places/${placeId}${sessionQuery}`, {
111
129
  method: 'GET',
112
130
  headers: {
113
131
  'Content-Type': 'application/json',
@@ -143,12 +161,12 @@ export async function getPlaceDetails(
143
161
  */
144
162
  export async function getPlaceCoordinates(
145
163
  placeId: string,
146
- apiKey: string
164
+ apiKey: string,
165
+ sessionToken?: string
147
166
  ): Promise<{ lat: number; lng: number } | null> {
148
- const details = await getPlaceDetails(placeId, apiKey);
167
+ const details = await getPlaceDetails(placeId, apiKey, sessionToken);
149
168
  if (details) {
150
169
  return { lat: details.lat, lng: details.lng };
151
170
  }
152
171
  return null;
153
172
  }
154
-
@@ -935,7 +935,8 @@ export async function validatePromoCode(
935
935
  promoCode: string,
936
936
  companyId: string,
937
937
  productId?: string,
938
- hasOngoingDiscount?: boolean
938
+ hasOngoingDiscount?: boolean,
939
+ dateTime?: string | null
939
940
  ): Promise<ValidatePromoResponse> {
940
941
  const params = new URLSearchParams({ promoCode: promoCode.trim(), companyId });
941
942
  const normalizedProductId = productId?.trim()
@@ -943,6 +944,7 @@ export async function validatePromoCode(
943
944
  : '';
944
945
  if (normalizedProductId) params.set('productId', normalizedProductId);
945
946
  if (hasOngoingDiscount === true) params.set('hasOngoingDiscount', 'true');
947
+ if (dateTime?.trim()) params.set('dateTime', dateTime.trim());
946
948
  const res = await fetchBookingGetWithRetry(`${API_BASE}/1/validate-promo?${params}`);
947
949
  if (!res.ok) {
948
950
  const err = await res.json();
@@ -964,6 +966,14 @@ export interface GetPromoDiscountBookingChangeOptions {
964
966
  forBookingChange?: boolean;
965
967
  /** Stored promo discount from the existing booking (major units, same currency as quote). */
966
968
  priorPromoDiscountAmount?: number;
969
+ /** Live available capacity for the selected slot, used by dynamic pricing. */
970
+ availabilityCount?: number;
971
+ /** Live total capacity for the selected slot, used by dynamic pricing. */
972
+ totalCapacity?: number;
973
+ /** Displayed ticket line totals from the cart, used to value vouchers against the visible price. */
974
+ displayedTicketLines?: Array<{ category: string; qty: number; itemTotal: number }>;
975
+ /** Displayed product fee total from the cart, excluding add-ons, return, and cancellation lines. */
976
+ displayedProductFeeTotal?: number;
967
977
  }
968
978
 
969
979
  export async function getPromoDiscount(
@@ -988,6 +998,32 @@ export async function getPromoDiscount(
988
998
  });
989
999
  if (dateTime) params.set('dateTime', dateTime);
990
1000
  if (subtotal != null && subtotal > 0) params.set('subtotal', String(subtotal));
1001
+ if (
1002
+ bookingChange?.availabilityCount != null &&
1003
+ Number.isFinite(bookingChange.availabilityCount)
1004
+ ) {
1005
+ params.set('availabilityCount', String(Math.trunc(bookingChange.availabilityCount)));
1006
+ }
1007
+ if (
1008
+ bookingChange?.totalCapacity != null &&
1009
+ Number.isFinite(bookingChange.totalCapacity)
1010
+ ) {
1011
+ params.set('totalCapacity', String(Math.trunc(bookingChange.totalCapacity)));
1012
+ }
1013
+ const displayedTicketLines = bookingChange?.displayedTicketLines
1014
+ ?.filter((line) => line.qty > 0 && Number.isFinite(line.itemTotal) && line.itemTotal >= 0)
1015
+ .map((line) => `${line.category}:${Math.trunc(line.qty)}:${line.itemTotal}`)
1016
+ .join(',');
1017
+ if (displayedTicketLines) {
1018
+ params.set('ticketLines', displayedTicketLines);
1019
+ }
1020
+ if (
1021
+ bookingChange?.displayedProductFeeTotal != null &&
1022
+ Number.isFinite(bookingChange.displayedProductFeeTotal) &&
1023
+ bookingChange.displayedProductFeeTotal >= 0
1024
+ ) {
1025
+ params.set('productFeeTotal', String(bookingChange.displayedProductFeeTotal));
1026
+ }
991
1027
  if (bookingChange?.forBookingChange === true) {
992
1028
  params.set('forBookingChange', 'true');
993
1029
  }