@ticketboothapp/booking 1.2.105 → 1.2.107
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 +1 -1
- package/src/components/booking/Calendar.tsx +16 -3
- package/src/components/booking/ChangeBookingFlow.tsx +147 -12
- package/src/components/booking/CheckoutForm.tsx +3 -0
- package/src/components/booking/NewBookingFlow.tsx +163 -14
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +11 -5
- package/src/lib/booking/booking-cutoffs.ts +67 -0
- package/src/lib/booking-api.ts +2 -0
- package/src/lib/booking-types.ts +2 -0
- package/src/lib/products-config.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
?
|
|
3900
|
+
? selectableTimesForDate.find(avail => getAvailabilityOptionId(avail) === mostPopularOption.optionId && avail.vacancies > 0)
|
|
3781
3901
|
: null;
|
|
3782
|
-
const fallback =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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
|
-
) :
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2808
|
-
(
|
|
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 &&
|
|
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
|
-
) :
|
|
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
|
-
|
|
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('');
|
|
@@ -930,6 +930,10 @@ export function PrivateShuttleBookingFlow({
|
|
|
930
930
|
const avail = dateAvailabilities.find(
|
|
931
931
|
(a) => getPrivateShuttleAvailabilityOptionId(a) === optionId
|
|
932
932
|
);
|
|
933
|
+
const profileOptionPrice = resourcePriceByOption?.[optionId]?.[currency];
|
|
934
|
+
if (profileOptionPrice != null) {
|
|
935
|
+
return applyResourceAdjustments(profileOptionPrice, avail, currency);
|
|
936
|
+
}
|
|
933
937
|
if (currency === 'CAD' && opt?.pricing?.['RESOURCE'] != null) {
|
|
934
938
|
return applyResourceAdjustments(opt.pricing['RESOURCE'], avail, currency);
|
|
935
939
|
}
|
|
@@ -939,8 +943,6 @@ export function PrivateShuttleBookingFlow({
|
|
|
939
943
|
return resourceRate.priceByCurrency[currency];
|
|
940
944
|
if (currency === 'CAD' && resourceRate?.price != null) return resourceRate.price;
|
|
941
945
|
}
|
|
942
|
-
if (resourcePriceByOption?.[optionId]?.[currency] != null)
|
|
943
|
-
return resourcePriceByOption[optionId][currency];
|
|
944
946
|
if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
|
|
945
947
|
return opt?.pricing?.['RESOURCE'] ?? 0;
|
|
946
948
|
},
|
|
@@ -955,6 +957,12 @@ export function PrivateShuttleBookingFlow({
|
|
|
955
957
|
);
|
|
956
958
|
|
|
957
959
|
const resourcePrice = useMemo(() => {
|
|
960
|
+
const selectedProfileOptionPrice = selectedOption
|
|
961
|
+
? resourcePriceByOption?.[selectedOption]?.[currency]
|
|
962
|
+
: undefined;
|
|
963
|
+
if (selectedProfileOptionPrice != null) {
|
|
964
|
+
return applyResourceAdjustments(selectedProfileOptionPrice, selectedAvailability, currency);
|
|
965
|
+
}
|
|
958
966
|
if (currency === 'CAD' && selectedOptionConfig?.pricing?.['RESOURCE'] != null)
|
|
959
967
|
return applyResourceAdjustments(
|
|
960
968
|
selectedOptionConfig.pricing['RESOURCE'],
|
|
@@ -970,8 +978,6 @@ export function PrivateShuttleBookingFlow({
|
|
|
970
978
|
if (fromRate != null) return fromRate;
|
|
971
979
|
}
|
|
972
980
|
}
|
|
973
|
-
if (selectedOption && resourcePriceByOption?.[selectedOption]?.[currency] != null)
|
|
974
|
-
return resourcePriceByOption[selectedOption][currency];
|
|
975
981
|
if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) return resourcePriceByCurrency[currency];
|
|
976
982
|
if (precomputedPrices?.['RESOURCE']?.[currency] != null)
|
|
977
983
|
return precomputedPrices['RESOURCE'][currency];
|
|
@@ -1544,7 +1550,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
1544
1550
|
const passengersToReserve = isSpecialRequest
|
|
1545
1551
|
? Math.min(passengerCount, maxVacancies)
|
|
1546
1552
|
: passengerCount;
|
|
1547
|
-
if (slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
|
|
1553
|
+
if (!isAdmin && slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
|
|
1548
1554
|
setError(
|
|
1549
1555
|
describePrivateShuttleCapacityConflictMessage({
|
|
1550
1556
|
passengersRequested: passengerCount,
|
|
@@ -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
|
+
}
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -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>;
|
package/src/lib/booking-types.ts
CHANGED
|
@@ -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
|
/**
|