@ticketboothapp/booking 1.2.122 → 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.
Files changed (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +647 -6157
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +154 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. 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
+ }