@ticketboothapp/booking 1.2.106 → 1.2.108

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.106",
3
+ "version": "1.2.108",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -4022,10 +4022,13 @@ export function AdminChangeBookingFlow({
4022
4022
  if (!originalMode) return hasAmount || currentLabel.length > 0;
4023
4023
  return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
4024
4024
  });
4025
+ const providerHasEditableSummaryLineOverrides =
4026
+ isProviderDashboardChange && Math.abs(editableSummaryPreSubtotalDelta) >= 0.005;
4025
4027
  const hasEffectiveChangeSelection =
4026
4028
  hasChangeSelection ||
4027
4029
  providerHasEditedLineOverrides ||
4028
4030
  providerHasAdditionalAdjustments ||
4031
+ providerHasEditableSummaryLineOverrides ||
4029
4032
  Math.abs(adminCustomAdjustmentTotal) >= 0.005;
4030
4033
 
4031
4034
  /**
@@ -4413,7 +4416,7 @@ export function AdminChangeBookingFlow({
4413
4416
  itinerarySteps,
4414
4417
  dateChanged: changeSelectionDetails.dateChanged,
4415
4418
  ticketsChanged: changeSelectionDetails.ticketsChanged,
4416
- hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
4419
+ hasChangesFromInitial: hasEffectiveChangeSelection,
4417
4420
  selectionTotal:
4418
4421
  originalReceipt
4419
4422
  ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
@@ -4429,6 +4432,7 @@ export function AdminChangeBookingFlow({
4429
4432
  computeItineraryDisplay,
4430
4433
  product.name,
4431
4434
  changeSelectionDetails,
4435
+ hasEffectiveChangeSelection,
4432
4436
  totalPrice,
4433
4437
  currency,
4434
4438
  originalReceipt,
@@ -26,6 +26,7 @@ export interface DateAvailability {
26
26
  totalVacancies?: number; // Total available tickets across all availabilities for this date
27
27
  startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
28
28
  soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
29
+ timeVacancies?: Record<string, number>; // Available tickets by start time
29
30
  totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
30
31
  /** Per-time capacity (admin): vacancies by default; booked/total only when the slot is sold out */
31
32
  timeCapacityMap?: Record<string, { booked: number; total: number; vacancies: number }>;
@@ -301,7 +302,11 @@ const DateCell = memo(function DateCell({
301
302
  )}>
302
303
  {availability.startTimes.slice(0, 3).map((time) => {
303
304
  const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
304
- const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
305
+ const timeVacancies =
306
+ availability.timeVacancies?.[time] ??
307
+ availability.timeCapacityMap?.[time]?.vacancies;
308
+ const isLowAvailability =
309
+ !isTimeSoldOut && timeVacancies !== undefined && timeVacancies < 5;
305
310
  const isDailyAvailability = time === '00:00';
306
311
  return (
307
312
  <div
@@ -328,7 +333,11 @@ const DateCell = memo(function DateCell({
328
333
  <div className="flex flex-wrap gap-0.5 justify-center">
329
334
  {availability.startTimes.slice(0, 3).map((time) => {
330
335
  const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
331
- const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
336
+ const timeVacancies =
337
+ availability.timeVacancies?.[time] ??
338
+ availability.timeCapacityMap?.[time]?.vacancies;
339
+ const isLowAvailability =
340
+ !isTimeSoldOut && timeVacancies !== undefined && timeVacancies < 5;
332
341
  const isDailyAvailability = time === '00:00';
333
342
  return (
334
343
  <div
@@ -360,7 +369,7 @@ const DateCell = memo(function DateCell({
360
369
  <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
361
370
  <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
362
371
  </svg>
363
- <span>{t('calendar.left', { count: availability.totalVacancies ?? 0 })}</span>
372
+ <span>{t('calendar.left', { count: timeVacancies ?? 0 })}</span>
364
373
  </div>
365
374
  )}
366
375
  </div>
@@ -868,6 +877,9 @@ export function Calendar({
868
877
  )
869
878
  .map(([time]) => time)
870
879
  );
880
+ const timeVacancies = Object.fromEntries(
881
+ Array.from(timeAvailabilityMap.entries()).map(([time, info]) => [time, info.vacancies])
882
+ );
871
883
 
872
884
  // Calculate total discount percentage from all negative adjustments
873
885
  const dynamicDiscountPercent = calculateTotalDiscountPercent(availabilities, currency) ?? 0;
@@ -882,6 +894,7 @@ export function Calendar({
882
894
  totalVacancies,
883
895
  startTimes: startTimes.length > 0 ? startTimes : undefined,
884
896
  soldOutTimes: soldOutTimes.size > 0 ? soldOutTimes : undefined,
897
+ timeVacancies: Object.keys(timeVacancies).length > 0 ? timeVacancies : undefined,
885
898
  totalDiscountPercent: totalDiscountPercent > 0 ? totalDiscountPercent : undefined,
886
899
  timeCapacityMap:
887
900
  Object.keys(timeCapacityMapWithVacancies).length > 0 ? timeCapacityMapWithVacancies : undefined,
@@ -93,6 +93,13 @@ import { useBookingHost } from '../../runtime';
93
93
  import type { BookingFlowUiOptions } from './booking-flow-ui';
94
94
  import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
95
95
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
96
+ import {
97
+ filterAvailabilitiesAfterPublicBookingCutoff,
98
+ isAvailabilityAfterPublicBookingCutoff,
99
+ isAvailabilityWithinPublicPickupUnknownCutoff,
100
+ isDateAfterPublicBookingCutoff,
101
+ parseBookingCutoffDateTime,
102
+ } from '../../lib/booking/booking-cutoffs';
96
103
 
97
104
  export type { ChangeBookingFlowProps } from './booking-flow-types';
98
105
 
@@ -730,6 +737,7 @@ export function ChangeBookingFlow({
730
737
  } = useBookingApp();
731
738
  const availabilitiesCache = useAvailabilitiesCache();
732
739
  const [availabilities, setAvailabilities] = useState<Availability[]>([]);
740
+ const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
733
741
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
734
742
  const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
735
743
  const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -859,6 +867,26 @@ export function ChangeBookingFlow({
859
867
  const isCustomerSelfServeChange = true;
860
868
  const isAdmin = false;
861
869
 
870
+ useEffect(() => {
871
+ const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
872
+ return () => window.clearInterval(interval);
873
+ }, []);
874
+
875
+ const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
876
+ const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
877
+
878
+ const hidePickupSkipOption = useMemo(
879
+ () =>
880
+ selectedAvailability != null &&
881
+ isAvailabilityWithinPublicPickupUnknownCutoff(selectedAvailability, bookingCutoffNow),
882
+ [bookingCutoffNow, selectedAvailability],
883
+ );
884
+
885
+ useEffect(() => {
886
+ if (!hidePickupSkipOption || !pickupLocationSkipped) return;
887
+ setPickupLocationSkipped(false);
888
+ }, [hidePickupSkipOption, pickupLocationSkipped]);
889
+
862
890
  useEffect(() => {
863
891
  setPartnerAttributionConfirmed(false);
864
892
  }, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
@@ -1487,10 +1515,15 @@ export function ChangeBookingFlow({
1487
1515
  }
1488
1516
  }, []);
1489
1517
 
1518
+ const publicVisibleAvailabilities = useMemo(
1519
+ () => filterAvailabilitiesAfterPublicBookingCutoff(availabilities, bookingCutoffNow, bookingCutoffMinutes),
1520
+ [availabilities, bookingCutoffMinutes, bookingCutoffNow],
1521
+ );
1522
+
1490
1523
  // Group availabilities by date (in company timezone) and sort by time
1491
1524
  // Memoized to prevent recalculation on every render
1492
1525
  const availabilitiesByDate = useMemo(() => {
1493
- const grouped = availabilities.reduce((acc, avail) => {
1526
+ const grouped = publicVisibleAvailabilities.reduce((acc, avail) => {
1494
1527
  // Parse the dateTime and extract the date in company timezone
1495
1528
  const dateTime = parseAvailabilityDateTime(avail.dateTime);
1496
1529
  const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
@@ -1510,7 +1543,7 @@ export function ChangeBookingFlow({
1510
1543
  });
1511
1544
 
1512
1545
  return sorted;
1513
- }, [availabilities, companyTimezone]);
1546
+ }, [publicVisibleAvailabilities, companyTimezone]);
1514
1547
 
1515
1548
  const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
1516
1549
 
@@ -2010,9 +2043,17 @@ export function ChangeBookingFlow({
2010
2043
  ? 0
2011
2044
  : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
2012
2045
 
2013
- return timesForSelectedDate.map(avail => {
2046
+ return timesForSelectedDate
2047
+ .filter((avail) => {
2048
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
2049
+ const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
2050
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
2051
+ : availabilityTime;
2052
+ return isDateAfterPublicBookingCutoff(pickupTime, bookingCutoffNow, bookingCutoffMinutes);
2053
+ })
2054
+ .map(avail => {
2014
2055
  // Parse the dateTime (which should already be in company timezone from backend)
2015
- const availabilityTime = parseISO(avail.dateTime);
2056
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
2016
2057
  const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
2017
2058
  const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
2018
2059
 
@@ -2043,6 +2084,8 @@ export function ChangeBookingFlow({
2043
2084
  };
2044
2085
  });
2045
2086
  }, [
2087
+ bookingCutoffNow,
2088
+ bookingCutoffMinutes,
2046
2089
  selectedDate,
2047
2090
  selectedPickupLocation,
2048
2091
  timesForSelectedDate,
@@ -2052,6 +2095,74 @@ export function ChangeBookingFlow({
2052
2095
  changeFlowSeatCreditForOutboundAvailability,
2053
2096
  ]);
2054
2097
 
2098
+ const isAvailabilityPickupAfterPublicBookingCutoff = useCallback(
2099
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
2100
+ const offsetMinutes = pickupLocationSkipped
2101
+ ? 0
2102
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
2103
+ const availabilityTime = parseBookingCutoffDateTime(availability.dateTime);
2104
+ const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
2105
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
2106
+ : availabilityTime;
2107
+ return isDateAfterPublicBookingCutoff(pickupTime, now, bookingCutoffMinutes);
2108
+ },
2109
+ [
2110
+ bookingCutoffNow,
2111
+ bookingCutoffMinutes,
2112
+ pickupLocationSkipped,
2113
+ selectedPickupLocation,
2114
+ ],
2115
+ );
2116
+
2117
+ const getPickupCutoffEligibleTimesForDate = useCallback(
2118
+ (date: string): Availability[] => {
2119
+ const times = availabilitiesByDate[date] ?? [];
2120
+ return times.filter((avail) =>
2121
+ isAvailabilityPickupAfterPublicBookingCutoff(avail),
2122
+ );
2123
+ },
2124
+ [
2125
+ availabilitiesByDate,
2126
+ isAvailabilityPickupAfterPublicBookingCutoff,
2127
+ ],
2128
+ );
2129
+
2130
+ const isPublicSelectableAvailability = useCallback(
2131
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
2132
+ if (!isAvailabilityAfterPublicBookingCutoff(availability, now, bookingCutoffMinutes)) return false;
2133
+ if (!isAvailabilityPickupAfterPublicBookingCutoff(availability, now)) return false;
2134
+ const availabilityDate = formatInTimeZone(
2135
+ parseBookingCutoffDateTime(availability.dateTime),
2136
+ companyTimezone,
2137
+ 'yyyy-MM-dd',
2138
+ );
2139
+ const optionId = getAvailabilityOptionId(availability);
2140
+ return getPickupCutoffEligibleTimesForDate(availabilityDate).some(
2141
+ (candidate) =>
2142
+ candidate.dateTime === availability.dateTime &&
2143
+ getAvailabilityOptionId(candidate) === optionId,
2144
+ );
2145
+ },
2146
+ [
2147
+ bookingCutoffMinutes,
2148
+ bookingCutoffNow,
2149
+ companyTimezone,
2150
+ getPickupCutoffEligibleTimesForDate,
2151
+ isAvailabilityPickupAfterPublicBookingCutoff,
2152
+ ],
2153
+ );
2154
+
2155
+ useEffect(() => {
2156
+ if (!selectedAvailability) return;
2157
+ if (!isPublicSelectableAvailability(selectedAvailability)) {
2158
+ setSelectedAvailability(null);
2159
+ setSelectedReturnOption(null);
2160
+ }
2161
+ }, [
2162
+ isPublicSelectableAvailability,
2163
+ selectedAvailability,
2164
+ ]);
2165
+
2055
2166
  // Check if any pickup time has "most popular" tag (memoized for performance)
2056
2167
  const hasAnyMostPopular = useMemo(() => {
2057
2168
  if (pickupTimes.length <= 1) return false;
@@ -3651,6 +3762,12 @@ export function ChangeBookingFlow({
3651
3762
  setChangeQuoteFetchError(null);
3652
3763
  return;
3653
3764
  }
3765
+ if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
3766
+ setLatestChangeQuote(null);
3767
+ setChangeQuoteLoading(false);
3768
+ setChangeQuoteFetchError(null);
3769
+ return;
3770
+ }
3654
3771
 
3655
3772
  setChangeQuoteLoading(true);
3656
3773
  setChangeQuoteFetchError(null);
@@ -3733,6 +3850,7 @@ export function ChangeBookingFlow({
3733
3850
  totalPrice,
3734
3851
  currency,
3735
3852
  activeOptions,
3853
+ isPublicSelectableAvailability,
3736
3854
  initialValues?.availabilityId,
3737
3855
  initialValues?.returnAvailabilityId,
3738
3856
  ]);
@@ -3741,6 +3859,8 @@ export function ChangeBookingFlow({
3741
3859
  // Change flow after a calendar change: prefer same product option, then closest departure time-of-day.
3742
3860
  useEffect(() => {
3743
3861
  if (selectedDate && timesForSelectedDate.length > 0 && !selectedAvailability) {
3862
+ const selectableTimesForDate = getPickupCutoffEligibleTimesForDate(selectedDate);
3863
+ if (selectableTimesForDate.length === 0) return;
3744
3864
  // Change flow on the booking's calendar day: pickup must come from resolveInitialAvailabilityFromBooking
3745
3865
  // (availabilityId / productOptionId / wall time). If we run "most popular" here too, both effects fire in
3746
3866
  // the same commit, this still sees selectedAvailability === null, and overwrites the correct slot.
@@ -3762,7 +3882,7 @@ export function ChangeBookingFlow({
3762
3882
  const anchor = changeFlowOutboundAnchorRef.current;
3763
3883
  if (anchor) {
3764
3884
  const matched = pickOutboundMatchingPreviousSelection(
3765
- timesForSelectedDate,
3885
+ selectableTimesForDate,
3766
3886
  anchor,
3767
3887
  companyTimezone,
3768
3888
  optionsMap
@@ -3777,9 +3897,9 @@ export function ChangeBookingFlow({
3777
3897
 
3778
3898
  const mostPopularOption = activeOptions.find(opt => opt.mostPopular);
3779
3899
  const candidate = mostPopularOption
3780
- ? timesForSelectedDate.find(avail => getAvailabilityOptionId(avail) === mostPopularOption.optionId && avail.vacancies > 0)
3900
+ ? selectableTimesForDate.find(avail => getAvailabilityOptionId(avail) === mostPopularOption.optionId && avail.vacancies > 0)
3781
3901
  : null;
3782
- const fallback = timesForSelectedDate.find(avail => avail.vacancies > 0);
3902
+ const fallback = selectableTimesForDate.find(avail => avail.vacancies > 0);
3783
3903
  const toSelect = candidate ?? fallback;
3784
3904
  if (toSelect) {
3785
3905
  setSelectedAvailability(toSelect);
@@ -3793,6 +3913,7 @@ export function ChangeBookingFlow({
3793
3913
  selectedAvailability,
3794
3914
  initialValues?.dateTime,
3795
3915
  companyTimezone,
3916
+ getPickupCutoffEligibleTimesForDate,
3796
3917
  ]);
3797
3918
 
3798
3919
  // Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
@@ -3963,7 +4084,7 @@ export function ChangeBookingFlow({
3963
4084
  // Match Calendar: first day where at least one departure can fit the party (self-serve change),
3964
4085
  // else first day with any inventory; admin fallback unchanged.
3965
4086
  const firstWithInventory = dates.find((d) => {
3966
- const rows = availabilitiesByDate[d] ?? [];
4087
+ const rows = getPickupCutoffEligibleTimesForDate(d);
3967
4088
  if (rows.length === 0) return false;
3968
4089
  if (isAdmin) return rows.some((a) => (a.vacancies ?? 0) > 0);
3969
4090
  if (
@@ -3998,7 +4119,7 @@ export function ChangeBookingFlow({
3998
4119
  initialValues?.dateTime,
3999
4120
  selectedDate,
4000
4121
  dates,
4001
- availabilitiesByDate,
4122
+ getPickupCutoffEligibleTimesForDate,
4002
4123
  isAdmin,
4003
4124
  isCustomerSelfServeChange,
4004
4125
  changeFlowInitialTicketCount,
@@ -4032,6 +4153,7 @@ export function ChangeBookingFlow({
4032
4153
  ]);
4033
4154
 
4034
4155
  const handleTimeSelect = (availability: Availability) => {
4156
+ if (!isPublicSelectableAvailability(availability, new Date())) return;
4035
4157
  setSelectedAvailability(availability);
4036
4158
  setSelectedReturnOption(null); // Clear return selection when changing start time
4037
4159
  setError('');
@@ -4174,6 +4296,12 @@ export function ChangeBookingFlow({
4174
4296
  setError(t('booking.selectTimeAndTickets'));
4175
4297
  return;
4176
4298
  }
4299
+ if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
4300
+ setSelectedAvailability(null);
4301
+ setSelectedReturnOption(null);
4302
+ setError('This departure is no longer available online because it starts too soon. Please choose a later time.');
4303
+ return;
4304
+ }
4177
4305
  if (missingRequiredReturnSelection) {
4178
4306
  setError('Removing return option in self-serve is not available. Please contact support.');
4179
4307
  return;
@@ -4203,7 +4331,12 @@ export function ChangeBookingFlow({
4203
4331
  }
4204
4332
 
4205
4333
  // Allow checkout if pickup location is selected OR if user chose "I don't know"
4206
- if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
4334
+ if (
4335
+ product.pickupLocations &&
4336
+ product.pickupLocations.length > 0 &&
4337
+ !pickupLocationId &&
4338
+ (!pickupLocationSkipped || hidePickupSkipOption)
4339
+ ) {
4207
4340
  setError(t('booking.selectPickupLocation'));
4208
4341
  return;
4209
4342
  }
@@ -4613,12 +4746,12 @@ export function ChangeBookingFlow({
4613
4746
  )}
4614
4747
  {isPartialLaunch ? null : (
4615
4748
  <div className="booking-calendar-section">
4616
- {loadingAvailabilities && availabilities.length === 0 ? (
4749
+ {loadingAvailabilities && publicVisibleAvailabilities.length === 0 ? (
4617
4750
  <div className="flex flex-col items-center justify-center py-12 gap-4">
4618
4751
  <div className="booking-loading-spinner" aria-hidden />
4619
4752
  <div className="text-stone-600">{t('booking.loadingTimes')}</div>
4620
4753
  </div>
4621
- ) : availabilities.length === 0 ? (
4754
+ ) : publicVisibleAvailabilities.length === 0 ? (
4622
4755
  <div className="text-center py-8 text-stone-500">
4623
4756
  {t('booking.noAvailability')}
4624
4757
  </div>
@@ -4841,6 +4974,7 @@ export function ChangeBookingFlow({
4841
4974
  pickupLocationSkipped={pickupLocationSkipped}
4842
4975
  selectedPickupLocation={selectedPickupLocation}
4843
4976
  highlightedPickupLocationIds={highlightedPickupLocationIds}
4977
+ hidePickupSkipOption={hidePickupSkipOption}
4844
4978
  onLocationSelect={(locationId) => {
4845
4979
  setPickupLocationId(locationId);
4846
4980
  setError('');
@@ -4851,6 +4985,7 @@ export function ChangeBookingFlow({
4851
4985
  }
4852
4986
  }}
4853
4987
  onSkip={() => {
4988
+ if (hidePickupSkipOption) return;
4854
4989
  setPickupLocationSkipped(true);
4855
4990
  setPickupLocationId(null);
4856
4991
  setError('');
@@ -55,6 +55,7 @@ interface CheckoutFormProps {
55
55
  pickupLocationSkipped: boolean;
56
56
  selectedPickupLocation: PickupLocation | null | undefined;
57
57
  highlightedPickupLocationIds?: string[];
58
+ hidePickupSkipOption?: boolean;
58
59
  onLocationSelect: (locationId: string | null, customLocation?: unknown) => void;
59
60
  onSkip: () => void;
60
61
  onChangePickup: () => void;
@@ -119,6 +120,7 @@ export function CheckoutForm({
119
120
  pickupLocationSkipped,
120
121
  selectedPickupLocation,
121
122
  highlightedPickupLocationIds,
123
+ hidePickupSkipOption = false,
122
124
  onLocationSelect,
123
125
  onSkip,
124
126
  onChangePickup,
@@ -335,6 +337,7 @@ export function CheckoutForm({
335
337
  isSkipped={pickupLocationSkipped}
336
338
  destinations={destinations}
337
339
  highlightedPickupLocationIds={highlightedPickupLocationIds}
340
+ hideSkipOption={hidePickupSkipOption}
338
341
  onLocationSelect={onLocationSelect}
339
342
  onSkip={onSkip}
340
343
  />
@@ -73,6 +73,13 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
73
73
  import type { NewBookingFlowProps } from './booking-flow-types';
74
74
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
75
75
  import { optionalCheckoutContactFields } from '../../lib/booking/checkout-contact';
76
+ import {
77
+ filterAvailabilitiesAfterPublicBookingCutoff,
78
+ isAvailabilityAfterPublicBookingCutoff,
79
+ isAvailabilityWithinPublicPickupUnknownCutoff,
80
+ isDateAfterPublicBookingCutoff,
81
+ parseBookingCutoffDateTime,
82
+ } from '../../lib/booking/booking-cutoffs';
76
83
 
77
84
  function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
78
85
  const labels: Record<string, string> = {
@@ -458,6 +465,7 @@ export function NewBookingFlow({
458
465
  const availabilitiesCache = useAvailabilitiesCache();
459
466
  const isAdmin = permissions.viewerRole === 'admin';
460
467
  const [availabilities, setAvailabilities] = useState<Availability[]>([]);
468
+ const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
461
469
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
462
470
  const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
463
471
  const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -620,6 +628,28 @@ export function NewBookingFlow({
620
628
  return map;
621
629
  }, [activeOptions]);
622
630
 
631
+ useEffect(() => {
632
+ if (isAdmin) return;
633
+ const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
634
+ return () => window.clearInterval(interval);
635
+ }, [isAdmin]);
636
+
637
+ const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
638
+ const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
639
+
640
+ const hidePickupSkipOption = useMemo(
641
+ () =>
642
+ !isAdmin &&
643
+ selectedAvailability != null &&
644
+ isAvailabilityWithinPublicPickupUnknownCutoff(selectedAvailability, bookingCutoffNow),
645
+ [bookingCutoffNow, isAdmin, selectedAvailability],
646
+ );
647
+
648
+ useEffect(() => {
649
+ if (!hidePickupSkipOption || !pickupLocationSkipped) return;
650
+ setPickupLocationSkipped(false);
651
+ }, [hidePickupSkipOption, pickupLocationSkipped]);
652
+
623
653
  // Fire view_item when product is first displayed
624
654
  const hasFiredViewItem = useRef(false);
625
655
  useEffect(() => {
@@ -1212,10 +1242,18 @@ export function NewBookingFlow({
1212
1242
  }
1213
1243
  }, []);
1214
1244
 
1245
+ const publicVisibleAvailabilities = useMemo(
1246
+ () =>
1247
+ isAdmin
1248
+ ? availabilities
1249
+ : filterAvailabilitiesAfterPublicBookingCutoff(availabilities, bookingCutoffNow, bookingCutoffMinutes),
1250
+ [availabilities, bookingCutoffMinutes, bookingCutoffNow, isAdmin],
1251
+ );
1252
+
1215
1253
  // Group availabilities by date (in company timezone) and sort by time
1216
1254
  // Memoized to prevent recalculation on every render
1217
1255
  const availabilitiesByDate = useMemo(() => {
1218
- const grouped = availabilities.reduce((acc, avail) => {
1256
+ const grouped = publicVisibleAvailabilities.reduce((acc, avail) => {
1219
1257
  // Parse the dateTime and extract the date in company timezone
1220
1258
  const dateTime = parseAvailabilityDateTime(avail.dateTime);
1221
1259
  const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
@@ -1235,7 +1273,7 @@ export function NewBookingFlow({
1235
1273
  });
1236
1274
 
1237
1275
  return sorted;
1238
- }, [availabilities, companyTimezone]);
1276
+ }, [publicVisibleAvailabilities, companyTimezone]);
1239
1277
 
1240
1278
  const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
1241
1279
 
@@ -1638,9 +1676,18 @@ export function NewBookingFlow({
1638
1676
  ? 0
1639
1677
  : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
1640
1678
 
1641
- return timesForSelectedDate.map(avail => {
1679
+ return timesForSelectedDate
1680
+ .filter((avail) => {
1681
+ if (isAdmin) return true;
1682
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
1683
+ const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
1684
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
1685
+ : availabilityTime;
1686
+ return isDateAfterPublicBookingCutoff(pickupTime, bookingCutoffNow, bookingCutoffMinutes);
1687
+ })
1688
+ .map(avail => {
1642
1689
  // Parse the dateTime (which should already be in company timezone from backend)
1643
- const availabilityTime = parseISO(avail.dateTime);
1690
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
1644
1691
  const adjustedVacancies = Math.max(0, avail.vacancies ?? 0);
1645
1692
 
1646
1693
  // Only apply offset if it's set and > 0 and location is selected
@@ -1670,6 +1717,9 @@ export function NewBookingFlow({
1670
1717
  };
1671
1718
  });
1672
1719
  }, [
1720
+ bookingCutoffNow,
1721
+ bookingCutoffMinutes,
1722
+ isAdmin,
1673
1723
  selectedDate,
1674
1724
  selectedPickupLocation,
1675
1725
  timesForSelectedDate,
@@ -1678,6 +1728,86 @@ export function NewBookingFlow({
1678
1728
  companyTimezone,
1679
1729
  ]);
1680
1730
 
1731
+ const isAvailabilityPickupAfterPublicBookingCutoff = useCallback(
1732
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
1733
+ if (isAdmin) return true;
1734
+ const offsetMinutes = pickupLocationSkipped
1735
+ ? 0
1736
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
1737
+ const availabilityTime = parseBookingCutoffDateTime(availability.dateTime);
1738
+ const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
1739
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
1740
+ : availabilityTime;
1741
+ return isDateAfterPublicBookingCutoff(pickupTime, now, bookingCutoffMinutes);
1742
+ },
1743
+ [
1744
+ bookingCutoffNow,
1745
+ bookingCutoffMinutes,
1746
+ isAdmin,
1747
+ pickupLocationSkipped,
1748
+ selectedPickupLocation,
1749
+ ],
1750
+ );
1751
+
1752
+ const getPickupCutoffEligibleTimesForDate = useCallback(
1753
+ (date: string): Availability[] => {
1754
+ const times = availabilitiesByDate[date] ?? [];
1755
+ return times.filter((avail) =>
1756
+ isAvailabilityPickupAfterPublicBookingCutoff(avail),
1757
+ );
1758
+ },
1759
+ [
1760
+ availabilitiesByDate,
1761
+ isAvailabilityPickupAfterPublicBookingCutoff,
1762
+ ],
1763
+ );
1764
+
1765
+ const isPublicSelectableAvailability = useCallback(
1766
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
1767
+ if (isAdmin) return true;
1768
+ if (!isAvailabilityAfterPublicBookingCutoff(availability, now, bookingCutoffMinutes)) return false;
1769
+ if (!isAvailabilityPickupAfterPublicBookingCutoff(availability, now)) return false;
1770
+ const availabilityDate = formatInTimeZone(
1771
+ parseBookingCutoffDateTime(availability.dateTime),
1772
+ companyTimezone,
1773
+ 'yyyy-MM-dd',
1774
+ );
1775
+ const optionId = getAvailabilityOptionId(availability);
1776
+ return getPickupCutoffEligibleTimesForDate(availabilityDate).some(
1777
+ (candidate) =>
1778
+ candidate.dateTime === availability.dateTime &&
1779
+ getAvailabilityOptionId(candidate) === optionId,
1780
+ );
1781
+ },
1782
+ [
1783
+ bookingCutoffMinutes,
1784
+ bookingCutoffNow,
1785
+ companyTimezone,
1786
+ getPickupCutoffEligibleTimesForDate,
1787
+ isAvailabilityPickupAfterPublicBookingCutoff,
1788
+ isAdmin,
1789
+ ],
1790
+ );
1791
+
1792
+ useEffect(() => {
1793
+ if (isAdmin || !selectedAvailability) return;
1794
+ const optionId = getAvailabilityOptionId(selectedAvailability);
1795
+ const stillSelectable = getPickupCutoffEligibleTimesForDate(selectedDate).some(
1796
+ (avail) =>
1797
+ avail.dateTime === selectedAvailability.dateTime &&
1798
+ getAvailabilityOptionId(avail) === optionId,
1799
+ );
1800
+ if (!stillSelectable) {
1801
+ setSelectedAvailability(null);
1802
+ setSelectedReturnOption(null);
1803
+ }
1804
+ }, [
1805
+ getPickupCutoffEligibleTimesForDate,
1806
+ isAdmin,
1807
+ selectedAvailability,
1808
+ selectedDate,
1809
+ ]);
1810
+
1681
1811
  // Check if any pickup time has "most popular" tag (memoized for performance)
1682
1812
  const hasAnyMostPopular = useMemo(() => {
1683
1813
  if (pickupTimes.length <= 1) return false;
@@ -2374,7 +2504,10 @@ export function NewBookingFlow({
2374
2504
  ) {
2375
2505
  return;
2376
2506
  }
2377
- const toSelect = pickDefaultAvailabilityForTimes(timesForSelectedDate, activeOptions);
2507
+ const toSelect = pickDefaultAvailabilityForTimes(
2508
+ getPickupCutoffEligibleTimesForDate(selectedDate),
2509
+ activeOptions,
2510
+ );
2378
2511
  if (toSelect) {
2379
2512
  setSelectedAvailability(toSelect);
2380
2513
  setError('');
@@ -2385,6 +2518,7 @@ export function NewBookingFlow({
2385
2518
  activeOptions,
2386
2519
  selectedAvailability,
2387
2520
  companyTimezone,
2521
+ getPickupCutoffEligibleTimesForDate,
2388
2522
  ]);
2389
2523
 
2390
2524
  // Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
@@ -2496,7 +2630,7 @@ export function NewBookingFlow({
2496
2630
  // Match Calendar: a day is "sold out" only when every slot has 0 vacancies.
2497
2631
  // Always auto-pick the first day with any inventory; only if the whole range is empty and user is admin, fall back to the first day.
2498
2632
  const firstWithInventory = dates.find((d) =>
2499
- (availabilitiesByDate[d] ?? []).some((a) => (a.vacancies ?? 0) > 0),
2633
+ getPickupCutoffEligibleTimesForDate(d).some((a) => (a.vacancies ?? 0) > 0),
2500
2634
  );
2501
2635
  const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
2502
2636
  if (!first) return;
@@ -2505,7 +2639,7 @@ export function NewBookingFlow({
2505
2639
  setSelectedDate(first);
2506
2640
  setSelectedReturnOption(null);
2507
2641
  setSelectedAvailability(
2508
- pickDefaultAvailabilityForTimes(availabilitiesByDate[first] ?? [], activeOptions),
2642
+ pickDefaultAvailabilityForTimes(getPickupCutoffEligibleTimesForDate(first), activeOptions),
2509
2643
  );
2510
2644
  if (!suppressCalendarDateScroll) {
2511
2645
  setTimeout(() => {
@@ -2523,7 +2657,7 @@ export function NewBookingFlow({
2523
2657
  initialValues?.dateTime,
2524
2658
  selectedDate,
2525
2659
  dates,
2526
- availabilitiesByDate,
2660
+ getPickupCutoffEligibleTimesForDate,
2527
2661
  isAdmin,
2528
2662
  useWindowScroll,
2529
2663
  contentRef,
@@ -2554,6 +2688,7 @@ export function NewBookingFlow({
2554
2688
  ]);
2555
2689
 
2556
2690
  const handleTimeSelect = (availability: Availability) => {
2691
+ if (!isPublicSelectableAvailability(availability, new Date())) return;
2557
2692
  setSelectedAvailability(availability);
2558
2693
  setSelectedReturnOption(null); // Clear return selection when changing start time
2559
2694
  setError('');
@@ -2731,6 +2866,12 @@ export function NewBookingFlow({
2731
2866
  setError(t('booking.selectTimeAndTickets'));
2732
2867
  return;
2733
2868
  }
2869
+ if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
2870
+ setSelectedAvailability(null);
2871
+ setSelectedReturnOption(null);
2872
+ setError('This departure is no longer available online because it starts too soon. Please choose a later time.');
2873
+ return;
2874
+ }
2734
2875
  if (!email) {
2735
2876
  setError(t('booking.enterEmail') || 'Please enter your email address');
2736
2877
  return;
@@ -2752,7 +2893,12 @@ export function NewBookingFlow({
2752
2893
  }
2753
2894
 
2754
2895
  // Allow checkout if pickup location is selected OR if user chose "I don't know"
2755
- if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
2896
+ if (
2897
+ product.pickupLocations &&
2898
+ product.pickupLocations.length > 0 &&
2899
+ !pickupLocationId &&
2900
+ (!pickupLocationSkipped || hidePickupSkipOption)
2901
+ ) {
2756
2902
  setError(t('booking.selectPickupLocation'));
2757
2903
  return;
2758
2904
  }
@@ -2804,8 +2950,9 @@ export function NewBookingFlow({
2804
2950
  const outboundVacancies = outbound?.vacancies ?? null;
2805
2951
  const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
2806
2952
  if (
2807
- (outboundVacancies != null && outboundVacancies < totalQuantity) ||
2808
- (selectedReturnOption && returnVacancies != null && returnVacancies < totalQuantity)
2953
+ !isAdmin &&
2954
+ ((outboundVacancies != null && outboundVacancies < totalQuantity) ||
2955
+ (selectedReturnOption && returnVacancies != null && returnVacancies < totalQuantity))
2809
2956
  ) {
2810
2957
  setError(
2811
2958
  describeStandardTourCapacityConflictMessage({
@@ -3408,12 +3555,12 @@ export function NewBookingFlow({
3408
3555
 
3409
3556
  {isPartialLaunch ? null : (
3410
3557
  <div className="booking-calendar-section">
3411
- {loadingAvailabilities && availabilities.length === 0 ? (
3558
+ {loadingAvailabilities && publicVisibleAvailabilities.length === 0 ? (
3412
3559
  <div className="flex flex-col items-center justify-center py-12 gap-4">
3413
3560
  <div className="booking-loading-spinner" aria-hidden />
3414
3561
  <div className="text-stone-600">{t('booking.loadingTimes')}</div>
3415
3562
  </div>
3416
- ) : availabilities.length === 0 ? (
3563
+ ) : publicVisibleAvailabilities.length === 0 ? (
3417
3564
  <div className="text-center py-8 text-stone-500">
3418
3565
  {t('booking.noAvailability')}
3419
3566
  </div>
@@ -3444,7 +3591,7 @@ export function NewBookingFlow({
3444
3591
  setSelectedReturnOption(null);
3445
3592
  setSelectedAvailability(
3446
3593
  pickDefaultAvailabilityForTimes(
3447
- availabilitiesByDate[date] ?? [],
3594
+ getPickupCutoffEligibleTimesForDate(date),
3448
3595
  activeOptions,
3449
3596
  ),
3450
3597
  );
@@ -3714,6 +3861,7 @@ export function NewBookingFlow({
3714
3861
  pickupLocationSkipped={pickupLocationSkipped}
3715
3862
  selectedPickupLocation={selectedPickupLocation}
3716
3863
  highlightedPickupLocationIds={highlightedPickupLocationIds}
3864
+ hidePickupSkipOption={hidePickupSkipOption}
3717
3865
  onLocationSelect={(locationId) => {
3718
3866
  setPickupLocationId(locationId);
3719
3867
  setError('');
@@ -3724,6 +3872,7 @@ export function NewBookingFlow({
3724
3872
  }
3725
3873
  }}
3726
3874
  onSkip={() => {
3875
+ if (hidePickupSkipOption) return;
3727
3876
  setPickupLocationSkipped(true);
3728
3877
  setPickupLocationId(null);
3729
3878
  setError('');
@@ -1550,7 +1550,7 @@ export function PrivateShuttleBookingFlow({
1550
1550
  const passengersToReserve = isSpecialRequest
1551
1551
  ? Math.min(passengerCount, maxVacancies)
1552
1552
  : passengerCount;
1553
- if (slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
1553
+ if (!isAdmin && slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
1554
1554
  setError(
1555
1555
  describePrivateShuttleCapacityConflictMessage({
1556
1556
  passengersRequested: passengerCount,
@@ -79,7 +79,12 @@
79
79
  },
80
80
  {
81
81
  "productId": "p_aaD7mhbGaZOi",
82
- "optionIds": ["po_BOw9d7qIWEhJ"],
82
+ "optionIds": ["po_EczO92oiIBKF", "po_BOw9d7qIWEhJ"],
83
+ "dependentAddOnProductIds": [
84
+ "dap_sM8pSzbFfU7P",
85
+ "dap_6tITweGzVyg8",
86
+ "dap_Xy3OCPIKQ7vr"
87
+ ],
83
88
  "display": {
84
89
  "path": "/lake-louise-shuttle",
85
90
  "slug": "lake-louise-adventure",
@@ -0,0 +1,67 @@
1
+ import { parseISO } from 'date-fns';
2
+
3
+ export const PUBLIC_BOOKING_CUTOFF_MINUTES = 60;
4
+ export const PUBLIC_PICKUP_UNKNOWN_CUTOFF_HOURS = 48;
5
+
6
+ const PUBLIC_PICKUP_UNKNOWN_CUTOFF_MS = PUBLIC_PICKUP_UNKNOWN_CUTOFF_HOURS * 60 * 60 * 1000;
7
+
8
+ export function parseBookingCutoffDateTime(value: string): Date {
9
+ // Match availability parsing elsewhere: API values without an offset are treated as UTC.
10
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
11
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
12
+ }
13
+
14
+ export function resolvePublicBookingCutoffMinutes(cutoffMinutes?: number | null): number {
15
+ if (cutoffMinutes == null) return PUBLIC_BOOKING_CUTOFF_MINUTES;
16
+ const parsed = Number(cutoffMinutes);
17
+ if (!Number.isFinite(parsed) || parsed < 0) return PUBLIC_BOOKING_CUTOFF_MINUTES;
18
+ return parsed;
19
+ }
20
+
21
+ export function publicBookingCutoffDate(
22
+ now: Date = new Date(),
23
+ cutoffMinutes?: number | null,
24
+ ): Date {
25
+ return new Date(now.getTime() + resolvePublicBookingCutoffMinutes(cutoffMinutes) * 60 * 1000);
26
+ }
27
+
28
+ export function isDateAfterPublicBookingCutoff(
29
+ date: Date,
30
+ now: Date = new Date(),
31
+ cutoffMinutes?: number | null,
32
+ ): boolean {
33
+ return date.getTime() >= publicBookingCutoffDate(now, cutoffMinutes).getTime();
34
+ }
35
+
36
+ export function isAvailabilityAfterPublicBookingCutoff(
37
+ availability: { dateTime: string },
38
+ now: Date = new Date(),
39
+ cutoffMinutes?: number | null,
40
+ ): boolean {
41
+ return isDateAfterPublicBookingCutoff(
42
+ parseBookingCutoffDateTime(availability.dateTime),
43
+ now,
44
+ cutoffMinutes,
45
+ );
46
+ }
47
+
48
+ export function filterAvailabilitiesAfterPublicBookingCutoff<T extends { dateTime: string }>(
49
+ availabilities: T[],
50
+ now: Date = new Date(),
51
+ cutoffMinutes?: number | null,
52
+ ): T[] {
53
+ return availabilities.filter((availability) =>
54
+ isAvailabilityAfterPublicBookingCutoff(availability, now, cutoffMinutes),
55
+ );
56
+ }
57
+
58
+ export function isDateWithinPublicPickupUnknownCutoff(date: Date, now: Date = new Date()): boolean {
59
+ return date.getTime() <= now.getTime() + PUBLIC_PICKUP_UNKNOWN_CUTOFF_MS;
60
+ }
61
+
62
+ export function isAvailabilityWithinPublicPickupUnknownCutoff(
63
+ availability: { dateTime: string },
64
+ now: Date = new Date(),
65
+ ): boolean {
66
+ return isDateWithinPublicPickupUnknownCutoff(parseBookingCutoffDateTime(availability.dateTime), now);
67
+ }
@@ -772,6 +772,8 @@ export interface Product {
772
772
  description: string | null;
773
773
  status: string;
774
774
  mostPopular?: boolean;
775
+ /** Optional public booking cutoff override in minutes. Defaults to 60 when omitted. */
776
+ bookingCutoffMinutes?: number | null;
775
777
  productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
776
778
  options: ProductOption[];
777
779
  minPriceByCurrency?: Record<string, number>;
@@ -18,6 +18,8 @@ export interface ProductDisplayConfig {
18
18
  export interface ProductConfig {
19
19
  productId: string;
20
20
  optionIds: string[];
21
+ /** Optional public booking cutoff override in minutes. Defaults to 60 when omitted. */
22
+ bookingCutoffMinutes?: number | null;
21
23
  /** STANDARD or PRIVATE_SHUTTLE - affects availability date format. Default STANDARD. */
22
24
  productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
23
25
  /**
@@ -60,6 +60,7 @@ export function buildMinimalProductFromConfig(
60
60
  name: config.display.shortName,
61
61
  description: null,
62
62
  status: 'ACTIVE',
63
+ bookingCutoffMinutes: config.bookingCutoffMinutes ?? null,
63
64
  productType: config.productType ?? 'STANDARD',
64
65
  options,
65
66
  };