@ticketboothapp/booking 1.2.123 → 1.2.124
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/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
- package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
- package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
- package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
- package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
- package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
- package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
- package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
- package/src/components/booking/CheckoutModal.tsx +68 -49
- package/src/components/booking/NewBookingFlow.tsx +485 -3505
- package/src/components/booking/PriceBreakdown.tsx +17 -3
- package/src/components/booking/PriceSummary.tsx +21 -3
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
- package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
- package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
- package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
- package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
- package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
- package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
- package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
- package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
- package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
- package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
- package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
- package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
- package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
- package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
- package/src/components/booking/admin-change-provider-payload.ts +101 -0
- package/src/components/booking/change-booking-checkout-builders.ts +185 -0
- package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
- package/src/components/booking/change-booking-flow-helpers.ts +706 -0
- package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
- package/src/components/booking/change-booking-quote-guards.ts +223 -0
- package/src/components/booking/change-booking-quote-state.ts +64 -0
- package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
- package/src/components/booking/private-shuttle-availability.ts +111 -0
- package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
- package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
- package/src/components/booking/standard-booking-availability.ts +305 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
- package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
- package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
- package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
- package/src/components/booking/use-private-shuttle-availability.ts +602 -0
- package/src/components/booking/use-standard-booking-availability.ts +806 -0
- package/src/components/booking/useActiveProductOptions.ts +26 -0
- package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
- package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
- package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
- package/src/components/booking/useAdminChangeProductReset.ts +60 -0
- package/src/components/booking/useAdminChangeProducts.ts +102 -0
- package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
- package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
- package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
- package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
- package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
- package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
- package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
- package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
- package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
- package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
- package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
- package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
- package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
- package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
- package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
- package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
- package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
- package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
- package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
- package/src/components/booking/useChangeBookingSelection.ts +362 -0
- package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
- package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
- package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
- package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
- package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
- package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
- package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
- package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
- package/src/components/booking/useStandardBookingPromoController.ts +258 -0
- package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
- package/src/data/products-config.json +1 -1
- package/src/lib/booking/change-booking-server-preview.ts +134 -20
- package/src/lib/booking/change-flow-pricing.ts +31 -11
- package/src/lib/booking/checkout-breakdown.ts +66 -1
- package/src/lib/booking/pricing-config.ts +10 -0
- package/src/lib/booking/source-metadata.ts +58 -0
- package/src/lib/booking-api.ts +154 -3
- package/src/lib/pricing-v2-shadow.ts +272 -0
- package/test/change-booking-helpers.test.ts +206 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { Availability } from '../../lib/booking-api';
|
|
5
|
+
|
|
6
|
+
export interface UseChangeBookingItineraryUiStateOptions {
|
|
7
|
+
selectedDate: string;
|
|
8
|
+
selectedAvailability: Availability | null;
|
|
9
|
+
useWindowScroll?: boolean;
|
|
10
|
+
stickyTopPx?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useChangeBookingItineraryUiState({
|
|
14
|
+
selectedDate,
|
|
15
|
+
selectedAvailability,
|
|
16
|
+
useWindowScroll = false,
|
|
17
|
+
stickyTopPx = 0,
|
|
18
|
+
}: UseChangeBookingItineraryUiStateOptions) {
|
|
19
|
+
const [isItinerarySticky, setIsItinerarySticky] = useState(false);
|
|
20
|
+
const isItineraryStickyRef = useRef(false);
|
|
21
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
22
|
+
const [showTooltip, setShowTooltip] = useState(false);
|
|
23
|
+
const itineraryRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const lastStickyChangeRef = useRef<number>(0);
|
|
25
|
+
|
|
26
|
+
const toggleTooltip = useCallback(() => {
|
|
27
|
+
setShowTooltip((prev) => !prev);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const checkMobile = () => {
|
|
32
|
+
setIsMobile(window.innerWidth < 640);
|
|
33
|
+
};
|
|
34
|
+
checkMobile();
|
|
35
|
+
window.addEventListener('resize', checkMobile);
|
|
36
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!showTooltip) return;
|
|
41
|
+
|
|
42
|
+
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
|
43
|
+
const target = e.target as HTMLElement;
|
|
44
|
+
if (!target.closest('[data-tooltip-icon]')) {
|
|
45
|
+
setShowTooltip(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
50
|
+
document.addEventListener('touchstart', handleClickOutside);
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
54
|
+
document.removeEventListener('touchstart', handleClickOutside);
|
|
55
|
+
};
|
|
56
|
+
}, [showTooltip]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const el = itineraryRef.current;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
|
|
62
|
+
const findScrollParent = (node: HTMLElement): HTMLElement | null => {
|
|
63
|
+
let parent = node.parentElement;
|
|
64
|
+
while (parent) {
|
|
65
|
+
const { overflowY } = getComputedStyle(parent);
|
|
66
|
+
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return parent;
|
|
67
|
+
parent = parent.parentElement;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const scrollParent = findScrollParent(el);
|
|
73
|
+
const scrollTarget =
|
|
74
|
+
useWindowScroll || !scrollParent
|
|
75
|
+
? (typeof window !== 'undefined' ? window : null)
|
|
76
|
+
: scrollParent;
|
|
77
|
+
|
|
78
|
+
let ticking = false;
|
|
79
|
+
const cooldownMs = 600;
|
|
80
|
+
const atTopBand = 48;
|
|
81
|
+
|
|
82
|
+
const updateStickyState = () => {
|
|
83
|
+
if (!itineraryRef.current) return;
|
|
84
|
+
|
|
85
|
+
const rect = itineraryRef.current.getBoundingClientRect();
|
|
86
|
+
const currentTop = rect.top;
|
|
87
|
+
const wasSticky = isItineraryStickyRef.current;
|
|
88
|
+
const containerTop =
|
|
89
|
+
scrollParent && !useWindowScroll ? scrollParent.getBoundingClientRect().top : 0;
|
|
90
|
+
const topInset = Math.max(0, stickyTopPx ?? 0);
|
|
91
|
+
const stickLine = containerTop + topInset;
|
|
92
|
+
const enterStickyThreshold = stickLine + 1;
|
|
93
|
+
const nextSticky = wasSticky
|
|
94
|
+
? currentTop >= stickLine - atTopBand && currentTop <= stickLine + atTopBand
|
|
95
|
+
: currentTop <= enterStickyThreshold;
|
|
96
|
+
|
|
97
|
+
if (nextSticky !== wasSticky) {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (now - lastStickyChangeRef.current < cooldownMs) return;
|
|
100
|
+
|
|
101
|
+
lastStickyChangeRef.current = now;
|
|
102
|
+
isItineraryStickyRef.current = nextSticky;
|
|
103
|
+
setIsItinerarySticky(nextSticky);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleScroll = () => {
|
|
108
|
+
if (!ticking) {
|
|
109
|
+
window.requestAnimationFrame(() => {
|
|
110
|
+
updateStickyState();
|
|
111
|
+
ticking = false;
|
|
112
|
+
});
|
|
113
|
+
ticking = true;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (scrollTarget) {
|
|
118
|
+
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
119
|
+
updateStickyState();
|
|
120
|
+
return () => scrollTarget.removeEventListener('scroll', handleScroll);
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}, [selectedDate, selectedAvailability, useWindowScroll, stickyTopPx]);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
isItinerarySticky,
|
|
127
|
+
isMobile,
|
|
128
|
+
showTooltip,
|
|
129
|
+
setShowTooltip,
|
|
130
|
+
toggleTooltip,
|
|
131
|
+
itineraryRef,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import { parseISO } from 'date-fns';
|
|
3
|
+
import { formatInTimeZone } from 'date-fns-tz';
|
|
4
|
+
import type {
|
|
5
|
+
Availability,
|
|
6
|
+
ItineraryDisplayStep,
|
|
7
|
+
PickupLocation,
|
|
8
|
+
Product,
|
|
9
|
+
ProductOption,
|
|
10
|
+
ReturnOption,
|
|
11
|
+
} from '../../lib/booking-api';
|
|
12
|
+
import { ItineraryStepType as StepType } from '../../lib/booking-api';
|
|
13
|
+
import {
|
|
14
|
+
isAvailabilityAfterPublicBookingCutoff,
|
|
15
|
+
isDateAfterPublicBookingCutoff,
|
|
16
|
+
parseBookingCutoffDateTime,
|
|
17
|
+
} from '../../lib/booking/booking-cutoffs';
|
|
18
|
+
import { getAvailabilityOptionId } from './change-booking-flow-helpers';
|
|
19
|
+
|
|
20
|
+
export interface PickupTimeInfo extends Availability {
|
|
21
|
+
pickupTime: string;
|
|
22
|
+
displayTime: string;
|
|
23
|
+
originalTime: string;
|
|
24
|
+
displayTimeRange?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type PublicBookingCutoffConfig = {
|
|
28
|
+
now: Date;
|
|
29
|
+
minutes?: number | null;
|
|
30
|
+
onSelectedAvailabilityExpired?: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type UseChangeBookingPickupItineraryParams = {
|
|
34
|
+
product: Pick<Product, 'destinations' | 'pickupLocations'>;
|
|
35
|
+
activeOptions: ProductOption[];
|
|
36
|
+
optionsMap: Map<string, ProductOption>;
|
|
37
|
+
selectedDate: string;
|
|
38
|
+
timesForSelectedDate: Availability[];
|
|
39
|
+
availabilitiesByDate: Record<string, Availability[]>;
|
|
40
|
+
selectedAvailability: Availability | null;
|
|
41
|
+
selectedReturnOption: ReturnOption | null;
|
|
42
|
+
pickupLocationId: string | null;
|
|
43
|
+
pickupLocationSkipped: boolean;
|
|
44
|
+
companyTimezone: string;
|
|
45
|
+
changeFlowSeatCreditForOutboundAvailability: (availability: Availability) => number;
|
|
46
|
+
publicBookingCutoff?: PublicBookingCutoffConfig;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type UseChangeBookingPickupItineraryResult = {
|
|
50
|
+
selectedPickupLocation: PickupLocation | undefined;
|
|
51
|
+
maxTimeOffsetMinutes: number;
|
|
52
|
+
pickupTimes: PickupTimeInfo[];
|
|
53
|
+
hasAnyMostPopular: boolean;
|
|
54
|
+
getPickupCutoffEligibleTimesForDate: (date: string) => Availability[];
|
|
55
|
+
isPublicSelectableAvailability: (availability: Availability, now?: Date) => boolean;
|
|
56
|
+
calculateStaySummary: (returnDateTime: Date) => string | null;
|
|
57
|
+
computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
|
|
58
|
+
computeItineraryDisplayForStorage: () => ItineraryDisplayStep[] | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function formatDurationHours(durationHours: number): string {
|
|
62
|
+
const hours = Math.floor(durationHours);
|
|
63
|
+
const minutes = Math.round((durationHours - hours) * 60);
|
|
64
|
+
if (hours === 0 && minutes === 0) return '0 min';
|
|
65
|
+
if (hours === 0) return `${minutes} min`;
|
|
66
|
+
if (minutes === 0) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
67
|
+
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useChangeBookingPickupItinerary({
|
|
71
|
+
product,
|
|
72
|
+
activeOptions,
|
|
73
|
+
optionsMap,
|
|
74
|
+
selectedDate,
|
|
75
|
+
timesForSelectedDate,
|
|
76
|
+
availabilitiesByDate,
|
|
77
|
+
selectedAvailability,
|
|
78
|
+
selectedReturnOption,
|
|
79
|
+
pickupLocationId,
|
|
80
|
+
pickupLocationSkipped,
|
|
81
|
+
companyTimezone,
|
|
82
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
83
|
+
publicBookingCutoff,
|
|
84
|
+
}: UseChangeBookingPickupItineraryParams): UseChangeBookingPickupItineraryResult {
|
|
85
|
+
const selectedPickupLocation = useMemo(
|
|
86
|
+
() => product.pickupLocations?.find((loc) => loc.id === pickupLocationId),
|
|
87
|
+
[product.pickupLocations, pickupLocationId],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const maxTimeOffsetMinutes = useMemo(() => {
|
|
91
|
+
if (!product.pickupLocations || product.pickupLocations.length === 0) return 0;
|
|
92
|
+
return Math.max(...product.pickupLocations.map((loc) => loc.pickupTimeOffsetMinutes ?? 0));
|
|
93
|
+
}, [product.pickupLocations]);
|
|
94
|
+
|
|
95
|
+
const hasPublicBookingCutoff = Boolean(publicBookingCutoff);
|
|
96
|
+
const publicCutoffNow = publicBookingCutoff?.now;
|
|
97
|
+
const publicCutoffMinutes = publicBookingCutoff?.minutes ?? null;
|
|
98
|
+
const onSelectedAvailabilityExpired = publicBookingCutoff?.onSelectedAvailabilityExpired;
|
|
99
|
+
|
|
100
|
+
const isAvailabilityPickupAfterPublicBookingCutoff = useCallback(
|
|
101
|
+
(availability: Availability, now: Date = publicCutoffNow ?? new Date()): boolean => {
|
|
102
|
+
if (!hasPublicBookingCutoff) return true;
|
|
103
|
+
const offsetMinutes = pickupLocationSkipped
|
|
104
|
+
? 0
|
|
105
|
+
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
106
|
+
const availabilityTime = parseBookingCutoffDateTime(availability.dateTime);
|
|
107
|
+
const pickupTime =
|
|
108
|
+
offsetMinutes > 0 && selectedPickupLocation
|
|
109
|
+
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
110
|
+
: availabilityTime;
|
|
111
|
+
return isDateAfterPublicBookingCutoff(pickupTime, now, publicCutoffMinutes);
|
|
112
|
+
},
|
|
113
|
+
[
|
|
114
|
+
pickupLocationSkipped,
|
|
115
|
+
hasPublicBookingCutoff,
|
|
116
|
+
publicCutoffMinutes,
|
|
117
|
+
publicCutoffNow,
|
|
118
|
+
selectedPickupLocation,
|
|
119
|
+
],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const getPickupCutoffEligibleTimesForDate = useCallback(
|
|
123
|
+
(date: string): Availability[] => {
|
|
124
|
+
const times = availabilitiesByDate[date] ?? [];
|
|
125
|
+
if (!hasPublicBookingCutoff) return times;
|
|
126
|
+
return times.filter((avail) => isAvailabilityPickupAfterPublicBookingCutoff(avail));
|
|
127
|
+
},
|
|
128
|
+
[availabilitiesByDate, hasPublicBookingCutoff, isAvailabilityPickupAfterPublicBookingCutoff],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const isPublicSelectableAvailability = useCallback(
|
|
132
|
+
(availability: Availability, now: Date = publicCutoffNow ?? new Date()): boolean => {
|
|
133
|
+
if (!hasPublicBookingCutoff) return true;
|
|
134
|
+
if (!isAvailabilityAfterPublicBookingCutoff(availability, now, publicCutoffMinutes)) return false;
|
|
135
|
+
if (!isAvailabilityPickupAfterPublicBookingCutoff(availability, now)) return false;
|
|
136
|
+
const availabilityDate = formatInTimeZone(
|
|
137
|
+
parseBookingCutoffDateTime(availability.dateTime),
|
|
138
|
+
companyTimezone,
|
|
139
|
+
'yyyy-MM-dd',
|
|
140
|
+
);
|
|
141
|
+
const optionId = getAvailabilityOptionId(availability);
|
|
142
|
+
return getPickupCutoffEligibleTimesForDate(availabilityDate).some(
|
|
143
|
+
(candidate) =>
|
|
144
|
+
candidate.dateTime === availability.dateTime &&
|
|
145
|
+
getAvailabilityOptionId(candidate) === optionId,
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
[
|
|
149
|
+
companyTimezone,
|
|
150
|
+
getPickupCutoffEligibleTimesForDate,
|
|
151
|
+
isAvailabilityPickupAfterPublicBookingCutoff,
|
|
152
|
+
hasPublicBookingCutoff,
|
|
153
|
+
publicCutoffMinutes,
|
|
154
|
+
publicCutoffNow,
|
|
155
|
+
],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!hasPublicBookingCutoff || !selectedAvailability) return;
|
|
160
|
+
if (!isPublicSelectableAvailability(selectedAvailability)) {
|
|
161
|
+
onSelectedAvailabilityExpired?.();
|
|
162
|
+
}
|
|
163
|
+
}, [
|
|
164
|
+
hasPublicBookingCutoff,
|
|
165
|
+
isPublicSelectableAvailability,
|
|
166
|
+
onSelectedAvailabilityExpired,
|
|
167
|
+
selectedAvailability,
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const pickupTimes = useMemo((): PickupTimeInfo[] => {
|
|
171
|
+
if (!selectedDate || !timesForSelectedDate.length) return [];
|
|
172
|
+
|
|
173
|
+
const offsetMinutes = pickupLocationSkipped
|
|
174
|
+
? 0
|
|
175
|
+
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
176
|
+
|
|
177
|
+
return timesForSelectedDate
|
|
178
|
+
.filter((avail) => isAvailabilityPickupAfterPublicBookingCutoff(avail))
|
|
179
|
+
.map((avail) => {
|
|
180
|
+
const availabilityTime = hasPublicBookingCutoff
|
|
181
|
+
? parseBookingCutoffDateTime(avail.dateTime)
|
|
182
|
+
: parseISO(avail.dateTime);
|
|
183
|
+
const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
|
|
184
|
+
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
|
|
185
|
+
const pickupTime =
|
|
186
|
+
offsetMinutes > 0 && selectedPickupLocation
|
|
187
|
+
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
188
|
+
: availabilityTime;
|
|
189
|
+
const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
|
|
190
|
+
const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
|
|
191
|
+
|
|
192
|
+
let displayTimeRange: string | undefined;
|
|
193
|
+
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
194
|
+
const latestPickupTime = new Date(
|
|
195
|
+
availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000,
|
|
196
|
+
);
|
|
197
|
+
const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
198
|
+
displayTimeRange = `${originalTime} - ${latestTimeStr}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
...avail,
|
|
203
|
+
vacancies: adjustedVacancies,
|
|
204
|
+
pickupTime: pickupTime.toISOString(),
|
|
205
|
+
displayTime,
|
|
206
|
+
originalTime,
|
|
207
|
+
displayTimeRange,
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}, [
|
|
211
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
212
|
+
companyTimezone,
|
|
213
|
+
isAvailabilityPickupAfterPublicBookingCutoff,
|
|
214
|
+
maxTimeOffsetMinutes,
|
|
215
|
+
pickupLocationSkipped,
|
|
216
|
+
hasPublicBookingCutoff,
|
|
217
|
+
selectedDate,
|
|
218
|
+
selectedPickupLocation,
|
|
219
|
+
timesForSelectedDate,
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const hasAnyMostPopular = useMemo(() => {
|
|
223
|
+
if (pickupTimes.length <= 1) return false;
|
|
224
|
+
return pickupTimes.some((time) => {
|
|
225
|
+
if (!time.productOptionId) return false;
|
|
226
|
+
const option = optionsMap.get(time.productOptionId);
|
|
227
|
+
return option?.mostPopular;
|
|
228
|
+
});
|
|
229
|
+
}, [optionsMap, pickupTimes]);
|
|
230
|
+
|
|
231
|
+
const getEffectiveItinerary = useCallback(
|
|
232
|
+
(option: ProductOption, date: Date): ProductOption['itinerary'] => {
|
|
233
|
+
if (!option.itinerary) return undefined;
|
|
234
|
+
const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
|
|
235
|
+
for (const override of option.itineraryOverrides ?? []) {
|
|
236
|
+
if (monthDay >= override.startDate && monthDay <= override.endDate) {
|
|
237
|
+
return override.itinerary;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return option.itinerary;
|
|
241
|
+
},
|
|
242
|
+
[companyTimezone],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const calculateStaySummary = useCallback(
|
|
246
|
+
(returnDateTime: Date): string | null => {
|
|
247
|
+
if (!selectedAvailability) return null;
|
|
248
|
+
const availabilityProductOptionId =
|
|
249
|
+
getAvailabilityOptionId(selectedAvailability) ||
|
|
250
|
+
(activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
|
|
251
|
+
const selectedOption = activeOptions.find((opt) => opt.optionId === availabilityProductOptionId);
|
|
252
|
+
if (!selectedOption) return null;
|
|
253
|
+
|
|
254
|
+
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
255
|
+
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
256
|
+
const hasItinerary =
|
|
257
|
+
itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
258
|
+
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
259
|
+
|
|
260
|
+
const destinationMap = new Map(product.destinations.map((destination) => [destination.name, destination]));
|
|
261
|
+
let lastDepartureTime = tourStartTime;
|
|
262
|
+
const stays: Array<{ destinationName: string; durationHours: number }> = [];
|
|
263
|
+
|
|
264
|
+
itinerary.forEach((itineraryItem, index) => {
|
|
265
|
+
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
266
|
+
if (!destination) return;
|
|
267
|
+
const arrivalTime = new Date(
|
|
268
|
+
lastDepartureTime.getTime() + itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000,
|
|
269
|
+
);
|
|
270
|
+
const isLastDestination = index === itinerary.length - 1;
|
|
271
|
+
|
|
272
|
+
if (isLastDestination) {
|
|
273
|
+
const timeDiffHours = (returnDateTime.getTime() - arrivalTime.getTime()) / (1000 * 60 * 60);
|
|
274
|
+
if (timeDiffHours > 0) {
|
|
275
|
+
stays.push({ destinationName: destination.name, durationHours: timeDiffHours });
|
|
276
|
+
}
|
|
277
|
+
} else if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
|
|
278
|
+
stays.push({ destinationName: destination.name, durationHours: itineraryItem.durationHours });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
lastDepartureTime =
|
|
282
|
+
itineraryItem.durationHours && !isLastDestination
|
|
283
|
+
? new Date(arrivalTime.getTime() + itineraryItem.durationHours * 60 * 60 * 1000)
|
|
284
|
+
: arrivalTime;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (stays.length === 0) return null;
|
|
288
|
+
return stays
|
|
289
|
+
.map((stay) => `${formatDurationHours(stay.durationHours)} at ${stay.destinationName}`)
|
|
290
|
+
.join(' + ');
|
|
291
|
+
},
|
|
292
|
+
[activeOptions, getEffectiveItinerary, product.destinations, selectedAvailability],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
|
|
296
|
+
if (!selectedAvailability) return null;
|
|
297
|
+
const availabilityProductOptionId =
|
|
298
|
+
getAvailabilityOptionId(selectedAvailability) ||
|
|
299
|
+
(activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
|
|
300
|
+
const selectedOption = activeOptions.find((opt) => opt.optionId === availabilityProductOptionId);
|
|
301
|
+
if (!selectedOption) return null;
|
|
302
|
+
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
303
|
+
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
304
|
+
const hasItinerary =
|
|
305
|
+
itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
306
|
+
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
307
|
+
|
|
308
|
+
const itineraryItems: ItineraryDisplayStep[] = [];
|
|
309
|
+
const destinationMap = new Map(product.destinations.map((destination) => [destination.name, destination]));
|
|
310
|
+
const pickupOffsetMinutes = pickupLocationSkipped
|
|
311
|
+
? 0
|
|
312
|
+
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
313
|
+
const actualPickupTime =
|
|
314
|
+
pickupOffsetMinutes > 0
|
|
315
|
+
? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
|
|
316
|
+
: tourStartTime;
|
|
317
|
+
const pickupTimeDisplay =
|
|
318
|
+
pickupLocationSkipped && maxTimeOffsetMinutes > 0
|
|
319
|
+
? `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(
|
|
320
|
+
new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000),
|
|
321
|
+
companyTimezone,
|
|
322
|
+
'h:mm a',
|
|
323
|
+
)}`
|
|
324
|
+
: formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
|
|
325
|
+
const pickupPlace =
|
|
326
|
+
pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
|
|
327
|
+
|
|
328
|
+
itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
|
|
329
|
+
|
|
330
|
+
let lastDepartureTime = tourStartTime;
|
|
331
|
+
itinerary.forEach((itineraryItem, index) => {
|
|
332
|
+
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
333
|
+
if (!destination) return;
|
|
334
|
+
const arrivalTime = new Date(
|
|
335
|
+
lastDepartureTime.getTime() + itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000,
|
|
336
|
+
);
|
|
337
|
+
itineraryItems.push({
|
|
338
|
+
stepType: StepType.arrive,
|
|
339
|
+
time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
|
|
340
|
+
place: destination.name,
|
|
341
|
+
});
|
|
342
|
+
const departureTime = itineraryItem.durationHours
|
|
343
|
+
? new Date(arrivalTime.getTime() + itineraryItem.durationHours * 60 * 60 * 1000)
|
|
344
|
+
: arrivalTime;
|
|
345
|
+
const hasMoreDestinations = index < itinerary.length - 1;
|
|
346
|
+
if (itineraryItem.durationHours && hasMoreDestinations) {
|
|
347
|
+
itineraryItems.push({
|
|
348
|
+
stepType: StepType.depart,
|
|
349
|
+
time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
|
|
350
|
+
place: destination.name,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
lastDepartureTime = departureTime;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const returnDateTime = selectedReturnOption ? parseISO(selectedReturnOption.dateTime) : lastDepartureTime;
|
|
357
|
+
const lastDestination =
|
|
358
|
+
itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
|
|
359
|
+
const lastDestName =
|
|
360
|
+
itinerary.length > 0
|
|
361
|
+
? destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null
|
|
362
|
+
: null;
|
|
363
|
+
const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
|
|
364
|
+
if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) {
|
|
365
|
+
return loc.travelMinutesFromDestination[lastDestName];
|
|
366
|
+
}
|
|
367
|
+
return 0;
|
|
368
|
+
};
|
|
369
|
+
const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
|
|
370
|
+
Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
|
|
371
|
+
const dropOffPlace =
|
|
372
|
+
pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
|
|
373
|
+
|
|
374
|
+
if (selectedReturnOption) {
|
|
375
|
+
itineraryItems.push({
|
|
376
|
+
stepType: StepType.depart,
|
|
377
|
+
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
378
|
+
place: lastDestination?.name ?? 'the_destination',
|
|
379
|
+
});
|
|
380
|
+
} else if (lastDestination) {
|
|
381
|
+
itineraryItems.push({
|
|
382
|
+
stepType: StepType.depart,
|
|
383
|
+
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
384
|
+
place: lastDestination.name,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
|
|
389
|
+
const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
|
|
390
|
+
const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
|
|
391
|
+
itineraryItems.push({
|
|
392
|
+
stepType: StepType.drop_off,
|
|
393
|
+
time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
|
|
394
|
+
place: dropOffPlace,
|
|
395
|
+
});
|
|
396
|
+
} else if (pickupLocationSkipped || !selectedPickupLocation) {
|
|
397
|
+
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
398
|
+
} else if (product.pickupLocations?.length) {
|
|
399
|
+
const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
|
|
400
|
+
const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
|
|
401
|
+
const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
|
|
402
|
+
const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
|
|
403
|
+
itineraryItems.push({
|
|
404
|
+
stepType: StepType.drop_off,
|
|
405
|
+
time:
|
|
406
|
+
minOffset === maxOffset
|
|
407
|
+
? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
|
|
408
|
+
: `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(
|
|
409
|
+
latestDropOff,
|
|
410
|
+
companyTimezone,
|
|
411
|
+
'h:mm a',
|
|
412
|
+
)}`,
|
|
413
|
+
place: dropOffPlace,
|
|
414
|
+
});
|
|
415
|
+
} else {
|
|
416
|
+
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return itineraryItems;
|
|
420
|
+
}, [
|
|
421
|
+
activeOptions,
|
|
422
|
+
companyTimezone,
|
|
423
|
+
getEffectiveItinerary,
|
|
424
|
+
maxTimeOffsetMinutes,
|
|
425
|
+
pickupLocationSkipped,
|
|
426
|
+
product,
|
|
427
|
+
selectedAvailability,
|
|
428
|
+
selectedPickupLocation,
|
|
429
|
+
selectedReturnOption,
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
|
|
433
|
+
const base = computeItineraryDisplay();
|
|
434
|
+
if (!base || base.length === 0) return base;
|
|
435
|
+
const pickupUnknown =
|
|
436
|
+
pickupLocationSkipped ||
|
|
437
|
+
(!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
|
|
438
|
+
if (!pickupUnknown) return base;
|
|
439
|
+
const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
|
|
440
|
+
if (!tourStartTime) return base;
|
|
441
|
+
const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
|
|
442
|
+
const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
|
|
443
|
+
const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
|
|
444
|
+
const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
445
|
+
const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
|
|
446
|
+
return base.map((step, index) =>
|
|
447
|
+
index === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
|
|
448
|
+
? { ...step, time: pickupRangeTime }
|
|
449
|
+
: step,
|
|
450
|
+
);
|
|
451
|
+
}, [
|
|
452
|
+
companyTimezone,
|
|
453
|
+
computeItineraryDisplay,
|
|
454
|
+
maxTimeOffsetMinutes,
|
|
455
|
+
pickupLocationSkipped,
|
|
456
|
+
product.pickupLocations,
|
|
457
|
+
selectedAvailability,
|
|
458
|
+
selectedPickupLocation,
|
|
459
|
+
]);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
selectedPickupLocation,
|
|
463
|
+
maxTimeOffsetMinutes,
|
|
464
|
+
pickupTimes,
|
|
465
|
+
hasAnyMostPopular,
|
|
466
|
+
getPickupCutoffEligibleTimesForDate,
|
|
467
|
+
isPublicSelectableAvailability,
|
|
468
|
+
calculateStaySummary,
|
|
469
|
+
computeItineraryDisplay,
|
|
470
|
+
computeItineraryDisplayForStorage,
|
|
471
|
+
};
|
|
472
|
+
}
|