@ticketboothapp/booking 1.2.112 → 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.112",
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",
@@ -76,8 +76,10 @@
76
76
  --cal-discount-tag-bg: rgba(239, 68, 68, 0.9);
77
77
  --cal-discount-tag-text: #fff;
78
78
 
79
- /* Today ring */
80
- --cal-today-ring: var(--booking-emerald-600, #059669);
79
+ /* Today marker */
80
+ --cal-today-ring: var(--booking-orange-600, #ff4d00);
81
+ --cal-today-text: var(--booking-orange-600, #ff4d00);
82
+ --cal-today-dot: var(--booking-orange-600, #ff4d00);
81
83
 
82
84
  /* Grid borders */
83
85
  --cal-grid-border: var(--booking-stone-300, #d6d3d1);
@@ -201,7 +203,8 @@
201
203
  --cal-dropdown-day-selected-bg-hover: var(--booking-emerald-700, #047857);
202
204
  --cal-dropdown-day-soldout-bg: #f87171;
203
205
  --cal-dropdown-day-soldout-bg-hover: #ef4444;
204
- --cal-dropdown-day-today-ring: var(--booking-emerald-500, #10b981);
206
+ --cal-dropdown-day-today-ring: var(--booking-orange-600, #ff4d00);
207
+ --cal-dropdown-day-today-text: var(--booking-orange-600, #ff4d00);
205
208
 
206
209
  position: fixed;
207
210
  background-color: var(--cal-dropdown-bg);
@@ -305,6 +308,7 @@
305
308
  transition: background-color 0.2s, color 0.2s;
306
309
  border: none;
307
310
  cursor: pointer;
311
+ position: relative;
308
312
  }
309
313
 
310
314
  .calendarDropdownDayMuted {
@@ -312,6 +316,12 @@
312
316
  cursor: not-allowed;
313
317
  }
314
318
 
319
+ .calendarDropdownDayPast {
320
+ background-color: var(--booking-stone-100, #f5f5f4);
321
+ color: var(--booking-stone-400, #a8a29e);
322
+ cursor: not-allowed;
323
+ }
324
+
315
325
  .calendarDropdownDayAvailable {
316
326
  color: var(--cal-dropdown-day-text);
317
327
  background-color: var(--cal-dropdown-day-available-bg);
@@ -348,6 +358,20 @@
348
358
 
349
359
  .calendarDropdownDayToday {
350
360
  box-shadow: inset 0 0 0 1px var(--cal-dropdown-day-today-ring);
361
+ color: var(--cal-dropdown-day-today-text);
362
+ font-weight: 700;
363
+ }
364
+
365
+ .calendarDropdownDayToday::after {
366
+ content: "";
367
+ position: absolute;
368
+ left: 50%;
369
+ bottom: 0.1875rem;
370
+ width: 0.25rem;
371
+ height: 0.25rem;
372
+ border-radius: 999px;
373
+ background-color: var(--cal-dropdown-day-today-ring);
374
+ transform: translateX(-50%);
351
375
  }
352
376
 
353
377
  .calendarGrid {
@@ -402,10 +426,10 @@
402
426
  }
403
427
  }
404
428
 
405
- /* Desktop: don't stretch every cell to the tallest day in the row (was huge empty space). */
429
+ /* Desktop: stretch each day to the row height so grid borders stay aligned across empty and available days. */
406
430
  @media (min-width: 640px) {
407
431
  .calendarDaysGrid {
408
- align-items: start;
432
+ align-items: stretch;
409
433
  }
410
434
 
411
435
  /* Admin capacity mode: keep all days in a row equal height (including empty days like leading/trailing month dates). */
@@ -456,15 +480,9 @@
456
480
  width: 100%;
457
481
  }
458
482
 
459
- /* Desktop: pin time pills to bottom of cell; centering left a tall empty band under the date. */
483
+ /* Desktop: center availability content while row stretching keeps grid borders aligned. */
460
484
  @media (min-width: 640px) {
461
485
  .calendarDayCellInner:not(.calendarDayCellInnerMobile) {
462
- justify-content: flex-end;
463
- padding-bottom: 0.125rem;
464
- }
465
-
466
- /* Admin capacity mode: with stretched row heights, bottom-pinning creates excessive top whitespace. */
467
- .calendar.calendarAdminMode .calendarDayCellInner:not(.calendarDayCellInnerMobile) {
468
486
  justify-content: center;
469
487
  padding-bottom: 0;
470
488
  }
@@ -517,8 +535,19 @@
517
535
  cursor: not-allowed;
518
536
  }
519
537
 
538
+ .calendar button.calendarDayCellPast,
539
+ .calendar .calendarDayCellPast {
540
+ background-color: var(--booking-stone-100, #f5f5f4);
541
+ color: var(--booking-stone-400, #a8a29e);
542
+ cursor: not-allowed;
543
+ }
544
+
545
+ .calendarDayCellPast .calendarDayNumber {
546
+ color: var(--booking-stone-400, #a8a29e);
547
+ }
548
+
520
549
  .calendarDayCellToday {
521
- box-shadow: inset 0 0 0 1px var(--cal-today-ring);
550
+ box-shadow: none;
522
551
  }
523
552
 
524
553
  /* Day number */
@@ -534,6 +563,22 @@
534
563
  color: var(--cal-day-number-selected);
535
564
  }
536
565
 
566
+ .calendarDayCellToday .calendarDayNumber {
567
+ color: var(--cal-today-text);
568
+ font-weight: 700;
569
+ }
570
+
571
+ .calendarDayCellToday .calendarDayNumber::after {
572
+ content: "";
573
+ display: inline-block;
574
+ width: 0.25rem;
575
+ height: 0.25rem;
576
+ margin-left: 0.1875rem;
577
+ border-radius: 999px;
578
+ background-color: var(--cal-today-dot);
579
+ vertical-align: 0.125rem;
580
+ }
581
+
537
582
  /* ============ BADGES & PILLS ============ */
538
583
  .calendarDiscountTag {
539
584
  position: absolute;
@@ -178,6 +178,7 @@ interface DateCellProps {
178
178
  availability: DateAvailability | null;
179
179
  isSelected: boolean;
180
180
  isToday: boolean;
181
+ isPast: boolean;
181
182
  showCapacity?: boolean;
182
183
  timezone: string;
183
184
  displayMode: 'times' | 'status';
@@ -192,6 +193,7 @@ const DateCell = memo(function DateCell({
192
193
  availability,
193
194
  isSelected,
194
195
  isToday,
196
+ isPast,
195
197
  showCapacity,
196
198
  timezone,
197
199
  displayMode,
@@ -209,7 +211,7 @@ const DateCell = memo(function DateCell({
209
211
  // In change flow, also allow the original booked sold-out date to stay selectable.
210
212
  const hasAvailability =
211
213
  hasAvailabilityData && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate);
212
- const isDisabled = !hasAvailability;
214
+ const isDisabled = isPast || !hasAvailability;
213
215
 
214
216
  // Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
215
217
  // Create a Date with those components, interpret as company timezone, then format.
@@ -246,21 +248,24 @@ const DateCell = memo(function DateCell({
246
248
  ? 'min-w-0 min-h-[5.75rem]'
247
249
  : 'min-h-[5rem]'
248
250
  ),
249
- isDisabled
251
+ isPast
252
+ ? styles.calendarDayCellPast
253
+ : isDisabled
250
254
  ? styles.calendarDayCellDisabled
251
255
  : isSelected
252
256
  ? styles.calendarDayCellSelected
253
257
  : styles.calendarDayCellAvailable,
254
- isToday && hasAvailability && !isSelected && styles.calendarDayCellToday
255
- ), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
258
+ isToday && !isPast && !isSelected && styles.calendarDayCellToday
259
+ ), [isPast, isDisabled, isSelected, isToday, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
256
260
 
257
261
  const ariaLabel = useMemo(() => {
258
262
  const parts = [dayNumber];
263
+ if (isPast) parts.push(' - Past date');
259
264
  if (availability?.isSoldOut) parts.push(' - Sold out');
260
265
  const pct = availability?.totalDiscountPercent;
261
266
  if (pct != null && pct > 0) parts.push(` - ${pct}% off`);
262
267
  return parts.join('');
263
- }, [dayNumber, availability?.isSoldOut, availability?.totalDiscountPercent]);
268
+ }, [dayNumber, isPast, availability?.isSoldOut, availability?.totalDiscountPercent]);
264
269
 
265
270
  return (
266
271
  <button
@@ -996,6 +1001,8 @@ export function Calendar({
996
1001
  getEffectiveVacancies,
997
1002
  ]);
998
1003
 
1004
+ const todayDateStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
1005
+
999
1006
  // Get display range for header
1000
1007
  const displayRange = useMemo(() => {
1001
1008
  const firstDay = calendarDays[0];
@@ -1022,6 +1029,8 @@ export function Calendar({
1022
1029
 
1023
1030
  const handleDateClick = (date: Date) => {
1024
1031
  const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
1032
+ if (dateStr < todayDateStr) return;
1033
+
1025
1034
  const availability = dateAvailabilityMap[dateStr] || null;
1026
1035
 
1027
1036
  const allowPinnedSoldOutDate = Boolean(
@@ -1165,6 +1174,7 @@ export function Calendar({
1165
1174
  const availability = dateAvailabilityMap[dateStr] || null;
1166
1175
  const isSelected = selectedDate === dateStr;
1167
1176
  const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
1177
+ const isPast = dateStr < todayDateStr;
1168
1178
  // Check if day is within allowed range (June 1 - October 12, 2026) in company timezone
1169
1179
  const isInAllowedRange = day >= miniCalendarBoundsInTz.start && day <= miniCalendarBoundsInTz.end;
1170
1180
 
@@ -1178,19 +1188,21 @@ export function Calendar({
1178
1188
  const isAvailable =
1179
1189
  hasAvailabilityData &&
1180
1190
  isInAllowedRange &&
1191
+ !isPast &&
1181
1192
  (!isSoldOut || showCapacity || allowPinnedSoldOutDate);
1182
1193
  const isToday = isSameDay(day, new Date());
1183
1194
 
1184
1195
  const dayClass = cn(
1185
1196
  styles.calendarDropdownDay,
1186
1197
  !isCurrentMonth && styles.calendarDropdownDayMuted,
1198
+ isPast && styles.calendarDropdownDayPast,
1187
1199
  isSoldOut && !showCapacity && styles.calendarDropdownDaySoldOut,
1188
1200
  isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
1189
1201
  !isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
1190
1202
  !hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
1191
1203
  isSelected && isAvailable && styles.calendarDropdownDaySelected,
1192
1204
  isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
1193
- isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
1205
+ isToday && !isPast && !isSelected && styles.calendarDropdownDayToday
1194
1206
  );
1195
1207
 
1196
1208
  return (
@@ -1276,6 +1288,7 @@ export function Calendar({
1276
1288
  const availability = dateAvailabilityMap[dateStr] || null;
1277
1289
  const isSelected = selectedDate === dateStr;
1278
1290
  const isToday = isSameDay(date, new Date());
1291
+ const isPast = dateStr < todayDateStr;
1279
1292
 
1280
1293
  // Include availability data in key to force re-render when availability changes
1281
1294
  const availabilityKey = availability
@@ -1289,6 +1302,7 @@ export function Calendar({
1289
1302
  availability={availability}
1290
1303
  isSelected={isSelected}
1291
1304
  isToday={isToday}
1305
+ isPast={isPast}
1292
1306
  showCapacity={showCapacity}
1293
1307
  timezone={timezone}
1294
1308
  displayMode={displayMode}
@@ -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
 
@@ -33,6 +33,10 @@ import {
33
33
  import { formatCurrencyAmount } from '../../lib/currency';
34
34
  import { formatBookingRefForDisplay } from '../../lib/booking-ref';
35
35
  import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
36
+ import {
37
+ isDateAfterPublicBookingCutoff,
38
+ resolvePublicBookingCutoffMinutes,
39
+ } from '../../lib/booking/booking-cutoffs';
36
40
  import type { PricingConfig, PrecomputedPricesByCategory } from '../../lib/booking-api';
37
41
  import { Calendar } from './Calendar';
38
42
  import { PickupLocationSelector } from './PickupLocationSelector';
@@ -122,6 +126,32 @@ function parseAvailabilityDateTime(value: string): Date {
122
126
  return parseISO(hasExplicitOffset ? value : `${value}Z`);
123
127
  }
124
128
 
129
+ function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
130
+ return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
131
+ }
132
+
133
+ function privateShuttleStartTimeAllowed(
134
+ dateStr: string,
135
+ timeStr: string,
136
+ timezone: string,
137
+ now: Date,
138
+ cutoffMinutes?: number | null,
139
+ ): boolean {
140
+ return isDateAfterPublicBookingCutoff(
141
+ privateShuttleStartDateTime(dateStr, timeStr, timezone),
142
+ now,
143
+ cutoffMinutes,
144
+ );
145
+ }
146
+
147
+ function privateShuttleBookingCutoffMessage(cutoffMinutes?: number | null): string {
148
+ const resolvedCutoffMinutes = resolvePublicBookingCutoffMinutes(cutoffMinutes);
149
+ if (resolvedCutoffMinutes % 60 === 0) {
150
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes / 60} hours before departure.`;
151
+ }
152
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes} minutes before departure.`;
153
+ }
154
+
125
155
  function findMergedPrivateShuttleAvailability(
126
156
  merged: Availability[],
127
157
  sel: Availability | null,
@@ -257,6 +287,7 @@ export function PrivateShuttleBookingFlow({
257
287
  const [loading, setLoading] = useState(false);
258
288
  const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
259
289
  const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
290
+ const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
260
291
  const [error, setError] = useState('');
261
292
  const [showCheckoutModal, setShowCheckoutModal] = useState(false);
262
293
  /** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
@@ -355,6 +386,14 @@ export function PrivateShuttleBookingFlow({
355
386
  () => product.options?.filter((opt) => opt.status === 'ACTIVE') || [],
356
387
  [product.options]
357
388
  );
389
+ const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
390
+ const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
391
+
392
+ useEffect(() => {
393
+ if (isAdmin) return;
394
+ const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
395
+ return () => window.clearInterval(interval);
396
+ }, [isAdmin]);
358
397
 
359
398
  // Fire view_item when product is first displayed
360
399
  const hasFiredViewItem = useRef(false);
@@ -780,9 +819,43 @@ export function PrivateShuttleBookingFlow({
780
819
  availabilitiesCache,
781
820
  ]);
782
821
 
822
+ const isPrivateShuttleAvailabilityBookable = useCallback(
823
+ (availability: Availability): boolean => {
824
+ if (isAdmin) return true;
825
+ const dateInCompanyTz = DATE_ONLY_REGEX.test(availability.dateTime)
826
+ ? availability.dateTime
827
+ : formatInTimeZone(parseAvailabilityDateTime(availability.dateTime), companyTimezone, 'yyyy-MM-dd');
828
+ const optionId = getPrivateShuttleAvailabilityOptionId(availability);
829
+ const option = activeOptions.find((opt) => opt.optionId === optionId);
830
+ const startTimes = availability.suggestedStartTimes || option?.privateShuttleConfig?.suggestedStartTimes || [];
831
+ if (startTimes.length === 0) {
832
+ return isDateAfterPublicBookingCutoff(
833
+ fromZonedTime(parseISO(`${dateInCompanyTz}T23:59:59`), companyTimezone),
834
+ bookingCutoffNow,
835
+ bookingCutoffMinutes,
836
+ );
837
+ }
838
+ return startTimes.some((time) =>
839
+ privateShuttleStartTimeAllowed(
840
+ dateInCompanyTz,
841
+ time,
842
+ companyTimezone,
843
+ bookingCutoffNow,
844
+ bookingCutoffMinutes,
845
+ ),
846
+ );
847
+ },
848
+ [activeOptions, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
849
+ );
850
+
851
+ const publicVisibleAvailabilities = useMemo(
852
+ () => (isAdmin ? availabilities : availabilities.filter(isPrivateShuttleAvailabilityBookable)),
853
+ [availabilities, isAdmin, isPrivateShuttleAvailabilityBookable],
854
+ );
855
+
783
856
  // Group availabilities by date (in company timezone - MDT). Availabilities from API are in UTC.
784
857
  const availabilitiesByDate = useMemo(() => {
785
- const grouped = availabilities.reduce(
858
+ const grouped = publicVisibleAvailabilities.reduce(
786
859
  (acc, avail) => {
787
860
  const dateInCompanyTz = DATE_ONLY_REGEX.test(avail.dateTime)
788
861
  ? avail.dateTime
@@ -803,7 +876,7 @@ export function PrivateShuttleBookingFlow({
803
876
  });
804
877
  });
805
878
  return sorted;
806
- }, [availabilities, companyTimezone]);
879
+ }, [publicVisibleAvailabilities, companyTimezone]);
807
880
 
808
881
  const optionsAvailableForSelectedDate = useMemo(() => {
809
882
  if (!selectedDate) return [];
@@ -914,8 +987,27 @@ export function PrivateShuttleBookingFlow({
914
987
  return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
915
988
  }, [dates, companyTimezone]);
916
989
 
917
- const suggestedStartTimes =
918
- selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
990
+ const suggestedStartTimes = useMemo(() => {
991
+ const times = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
992
+ if (isAdmin || !selectedDate) return times;
993
+ return times.filter((time) =>
994
+ privateShuttleStartTimeAllowed(
995
+ selectedDate,
996
+ time,
997
+ companyTimezone,
998
+ bookingCutoffNow,
999
+ bookingCutoffMinutes,
1000
+ ),
1001
+ );
1002
+ }, [
1003
+ bookingCutoffMinutes,
1004
+ bookingCutoffNow,
1005
+ companyTimezone,
1006
+ isAdmin,
1007
+ privateShuttleConfig?.suggestedStartTimes,
1008
+ selectedAvailability?.suggestedStartTimes,
1009
+ selectedDate,
1010
+ ]);
919
1011
  const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
920
1012
  const maxVacancies = selectedAvailability?.vacancies || 0;
921
1013
  const isSpecialRequest = passengerCount > maxVacancies;
@@ -1255,6 +1347,13 @@ export function PrivateShuttleBookingFlow({
1255
1347
  }
1256
1348
  }, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
1257
1349
 
1350
+ useEffect(() => {
1351
+ if (isAdmin || isCustomTimeMode || !selectedStartTime) return;
1352
+ if (!suggestedStartTimes.includes(selectedStartTime)) {
1353
+ setSelectedStartTime('');
1354
+ }
1355
+ }, [isAdmin, isCustomTimeMode, selectedStartTime, suggestedStartTimes]);
1356
+
1258
1357
  useEffect(() => {
1259
1358
  if (
1260
1359
  pricingConfig?.cancellationPolicies &&
@@ -1282,6 +1381,14 @@ export function PrivateShuttleBookingFlow({
1282
1381
  };
1283
1382
 
1284
1383
  const handleStartTimeSelect = (time: string) => {
1384
+ if (
1385
+ !isAdmin &&
1386
+ selectedDate &&
1387
+ !privateShuttleStartTimeAllowed(selectedDate, time, companyTimezone, bookingCutoffNow, bookingCutoffMinutes)
1388
+ ) {
1389
+ setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
1390
+ return;
1391
+ }
1285
1392
  setSelectedStartTime(time);
1286
1393
  setIsCustomTimeMode(false);
1287
1394
  setError('');
@@ -1465,6 +1572,19 @@ export function PrivateShuttleBookingFlow({
1465
1572
  setError('Please select a date, start time, and passenger count');
1466
1573
  return;
1467
1574
  }
1575
+ if (
1576
+ !isAdmin &&
1577
+ !privateShuttleStartTimeAllowed(
1578
+ selectedDate,
1579
+ selectedStartTime,
1580
+ companyTimezone,
1581
+ bookingCutoffNow,
1582
+ bookingCutoffMinutes,
1583
+ )
1584
+ ) {
1585
+ setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
1586
+ return;
1587
+ }
1468
1588
  if (
1469
1589
  product.pickupLocations &&
1470
1590
  product.pickupLocations.length > 0 &&
@@ -113,6 +113,7 @@
113
113
  "productId": "p_urmbvZepRegd",
114
114
  "optionIds": ["po_uO9Ra6NHzwof", "po_44EQlZn24gXg", "po_vuzqpvGk6wVS", "po_nIToR7Qwau3E"],
115
115
  "productType": "PRIVATE_SHUTTLE",
116
+ "bookingCutoffMinutes": 2880,
116
117
  "display": {
117
118
  "path": "/private-shuttle",
118
119
  "slug": "private-tour",
@@ -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
  }