@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,473 @@
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 './standard-booking-availability';
19
+
20
+ export interface StandardPickupTimeInfo extends Availability {
21
+ pickupTime: string;
22
+ displayTime: string;
23
+ originalTime: string;
24
+ displayTimeRange?: string;
25
+ }
26
+
27
+ type UseStandardBookingPickupItineraryParams = {
28
+ product: Pick<Product, 'destinations' | 'pickupLocations'>;
29
+ activeOptions: ProductOption[];
30
+ optionsMap: Map<string, ProductOption>;
31
+ selectedDate: string;
32
+ timesForSelectedDate: Availability[];
33
+ availabilitiesByDate: Record<string, Availability[]>;
34
+ selectedAvailability: Availability | null;
35
+ selectedReturnOption: ReturnOption | null;
36
+ pickupLocationId: string | null;
37
+ pickupLocationSkipped: boolean;
38
+ companyTimezone: string;
39
+ isAdmin: boolean;
40
+ bookingCutoffNow: Date;
41
+ bookingCutoffMinutes?: number | null;
42
+ onSelectedAvailabilityExpired: () => void;
43
+ };
44
+
45
+ type UseStandardBookingPickupItineraryResult = {
46
+ selectedPickupLocation: PickupLocation | undefined;
47
+ pickupTimes: StandardPickupTimeInfo[];
48
+ hasAnyMostPopular: boolean;
49
+ getPickupCutoffEligibleTimesForDate: (date: string) => Availability[];
50
+ isPublicSelectableAvailability: (availability: Availability, now?: Date) => boolean;
51
+ calculateStaySummary: (returnDateTime: Date) => string | null;
52
+ computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
53
+ computeItineraryDisplayForStorage: () => ItineraryDisplayStep[] | null;
54
+ };
55
+
56
+ function formatDurationHours(durationHours: number): string {
57
+ const hours = Math.floor(durationHours);
58
+ const minutes = Math.round((durationHours - hours) * 60);
59
+ if (hours === 0 && minutes === 0) return '0 min';
60
+ if (hours === 0) return `${minutes} min`;
61
+ if (minutes === 0) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
62
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
63
+ }
64
+
65
+ export function useStandardBookingPickupItinerary({
66
+ product,
67
+ activeOptions,
68
+ optionsMap,
69
+ selectedDate,
70
+ timesForSelectedDate,
71
+ availabilitiesByDate,
72
+ selectedAvailability,
73
+ selectedReturnOption,
74
+ pickupLocationId,
75
+ pickupLocationSkipped,
76
+ companyTimezone,
77
+ isAdmin,
78
+ bookingCutoffNow,
79
+ bookingCutoffMinutes,
80
+ onSelectedAvailabilityExpired,
81
+ }: UseStandardBookingPickupItineraryParams): UseStandardBookingPickupItineraryResult {
82
+ const selectedPickupLocation = useMemo(
83
+ () => product.pickupLocations?.find((loc) => loc.id === pickupLocationId),
84
+ [product.pickupLocations, pickupLocationId],
85
+ );
86
+
87
+ const maxTimeOffsetMinutes = useMemo(() => {
88
+ if (!product.pickupLocations || product.pickupLocations.length === 0) return 0;
89
+ return Math.max(...product.pickupLocations.map((loc) => loc.pickupTimeOffsetMinutes ?? 0));
90
+ }, [product.pickupLocations]);
91
+
92
+ const pickupTimes = useMemo((): StandardPickupTimeInfo[] => {
93
+ if (!selectedDate || !timesForSelectedDate.length) return [];
94
+
95
+ const offsetMinutes = pickupLocationSkipped
96
+ ? 0
97
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
98
+
99
+ return timesForSelectedDate
100
+ .filter((avail) => {
101
+ if (isAdmin) return true;
102
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
103
+ const pickupTime =
104
+ offsetMinutes > 0 && selectedPickupLocation
105
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
106
+ : availabilityTime;
107
+ return isDateAfterPublicBookingCutoff(
108
+ pickupTime,
109
+ bookingCutoffNow,
110
+ bookingCutoffMinutes,
111
+ );
112
+ })
113
+ .map((avail) => {
114
+ const availabilityTime = parseBookingCutoffDateTime(avail.dateTime);
115
+ const adjustedVacancies = Math.max(0, avail.vacancies ?? 0);
116
+ const pickupTime =
117
+ offsetMinutes > 0 && selectedPickupLocation
118
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
119
+ : availabilityTime;
120
+ const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
121
+ const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
122
+
123
+ let displayTimeRange: string | undefined;
124
+ if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
125
+ const latestPickupTime = new Date(
126
+ availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000,
127
+ );
128
+ const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
129
+ displayTimeRange = `${originalTime} - ${latestTimeStr}`;
130
+ }
131
+
132
+ return {
133
+ ...avail,
134
+ vacancies: adjustedVacancies,
135
+ pickupTime: pickupTime.toISOString(),
136
+ displayTime,
137
+ originalTime,
138
+ displayTimeRange,
139
+ };
140
+ });
141
+ }, [
142
+ bookingCutoffMinutes,
143
+ bookingCutoffNow,
144
+ companyTimezone,
145
+ isAdmin,
146
+ maxTimeOffsetMinutes,
147
+ pickupLocationSkipped,
148
+ selectedDate,
149
+ selectedPickupLocation,
150
+ timesForSelectedDate,
151
+ ]);
152
+
153
+ const isAvailabilityPickupAfterPublicBookingCutoff = useCallback(
154
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
155
+ if (isAdmin) return true;
156
+ const offsetMinutes = pickupLocationSkipped
157
+ ? 0
158
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
159
+ const availabilityTime = parseBookingCutoffDateTime(availability.dateTime);
160
+ const pickupTime =
161
+ offsetMinutes > 0 && selectedPickupLocation
162
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
163
+ : availabilityTime;
164
+ return isDateAfterPublicBookingCutoff(pickupTime, now, bookingCutoffMinutes);
165
+ },
166
+ [
167
+ bookingCutoffMinutes,
168
+ bookingCutoffNow,
169
+ isAdmin,
170
+ pickupLocationSkipped,
171
+ selectedPickupLocation,
172
+ ],
173
+ );
174
+
175
+ const getPickupCutoffEligibleTimesForDate = useCallback(
176
+ (date: string): Availability[] => {
177
+ const times = availabilitiesByDate[date] ?? [];
178
+ return times.filter((avail) => isAvailabilityPickupAfterPublicBookingCutoff(avail));
179
+ },
180
+ [availabilitiesByDate, isAvailabilityPickupAfterPublicBookingCutoff],
181
+ );
182
+
183
+ const isPublicSelectableAvailability = useCallback(
184
+ (availability: Availability, now: Date = bookingCutoffNow): boolean => {
185
+ if (isAdmin) return true;
186
+ if (!isAvailabilityAfterPublicBookingCutoff(availability, now, bookingCutoffMinutes)) {
187
+ return false;
188
+ }
189
+ if (!isAvailabilityPickupAfterPublicBookingCutoff(availability, now)) return false;
190
+ const availabilityDate = formatInTimeZone(
191
+ parseBookingCutoffDateTime(availability.dateTime),
192
+ companyTimezone,
193
+ 'yyyy-MM-dd',
194
+ );
195
+ const optionId = getAvailabilityOptionId(availability);
196
+ return getPickupCutoffEligibleTimesForDate(availabilityDate).some(
197
+ (candidate) =>
198
+ candidate.dateTime === availability.dateTime &&
199
+ getAvailabilityOptionId(candidate) === optionId,
200
+ );
201
+ },
202
+ [
203
+ bookingCutoffMinutes,
204
+ bookingCutoffNow,
205
+ companyTimezone,
206
+ getPickupCutoffEligibleTimesForDate,
207
+ isAvailabilityPickupAfterPublicBookingCutoff,
208
+ isAdmin,
209
+ ],
210
+ );
211
+
212
+ useEffect(() => {
213
+ if (isAdmin || !selectedAvailability) return;
214
+ if (!isPublicSelectableAvailability(selectedAvailability)) {
215
+ onSelectedAvailabilityExpired();
216
+ }
217
+ }, [
218
+ isAdmin,
219
+ isPublicSelectableAvailability,
220
+ onSelectedAvailabilityExpired,
221
+ selectedAvailability,
222
+ ]);
223
+
224
+ const hasAnyMostPopular = useMemo(() => {
225
+ if (pickupTimes.length <= 1) return false;
226
+ return pickupTimes.some((time) => {
227
+ if (!time.productOptionId) return false;
228
+ const option = optionsMap.get(time.productOptionId);
229
+ return option?.mostPopular;
230
+ });
231
+ }, [optionsMap, pickupTimes]);
232
+
233
+ const getEffectiveItinerary = useCallback(
234
+ (option: ProductOption, date: Date): ProductOption['itinerary'] => {
235
+ if (!option.itinerary) return undefined;
236
+ const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
237
+ for (const override of option.itineraryOverrides ?? []) {
238
+ if (monthDay >= override.startDate && monthDay <= override.endDate) {
239
+ return override.itinerary;
240
+ }
241
+ }
242
+ return option.itinerary;
243
+ },
244
+ [companyTimezone],
245
+ );
246
+
247
+ const calculateStaySummary = useCallback(
248
+ (returnDateTime: Date): string | null => {
249
+ if (!selectedAvailability) return null;
250
+ const availabilityProductOptionId =
251
+ getAvailabilityOptionId(selectedAvailability) ||
252
+ (activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
253
+ const selectedOption = activeOptions.find((opt) => opt.optionId === availabilityProductOptionId);
254
+ if (!selectedOption) return null;
255
+
256
+ const tourStartTime = parseISO(selectedAvailability.dateTime);
257
+ const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
258
+ const hasItinerary =
259
+ itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
260
+ if (!hasItinerary || !product.destinations || !itinerary) return null;
261
+
262
+ const destinationMap = new Map(product.destinations.map((destination) => [destination.name, destination]));
263
+ let lastDepartureTime = tourStartTime;
264
+ const stays: Array<{ destinationName: string; durationHours: number }> = [];
265
+
266
+ itinerary.forEach((itineraryItem, index) => {
267
+ const destination = destinationMap.get(itineraryItem.destinationName);
268
+ if (!destination) return;
269
+ const arrivalTime = new Date(
270
+ lastDepartureTime.getTime() + itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000,
271
+ );
272
+ const isLastDestination = index === itinerary.length - 1;
273
+
274
+ if (isLastDestination) {
275
+ const timeDiffHours = (returnDateTime.getTime() - arrivalTime.getTime()) / (1000 * 60 * 60);
276
+ if (timeDiffHours > 0) {
277
+ stays.push({ destinationName: destination.name, durationHours: timeDiffHours });
278
+ }
279
+ } else if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
280
+ stays.push({ destinationName: destination.name, durationHours: itineraryItem.durationHours });
281
+ }
282
+
283
+ lastDepartureTime =
284
+ itineraryItem.durationHours && !isLastDestination
285
+ ? new Date(arrivalTime.getTime() + itineraryItem.durationHours * 60 * 60 * 1000)
286
+ : arrivalTime;
287
+ });
288
+
289
+ if (stays.length === 0) return null;
290
+ return stays
291
+ .map((stay) => `${formatDurationHours(stay.durationHours)} at ${stay.destinationName}`)
292
+ .join(' + ');
293
+ },
294
+ [activeOptions, getEffectiveItinerary, product.destinations, selectedAvailability],
295
+ );
296
+
297
+ const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
298
+ if (!selectedAvailability) return null;
299
+ const availabilityProductOptionId =
300
+ getAvailabilityOptionId(selectedAvailability) ||
301
+ (activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
302
+ const selectedOption = activeOptions.find((opt) => opt.optionId === availabilityProductOptionId);
303
+ if (!selectedOption) return null;
304
+ const tourStartTime = parseISO(selectedAvailability.dateTime);
305
+ const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
306
+ const hasItinerary =
307
+ itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
308
+ if (!hasItinerary || !product.destinations || !itinerary) return null;
309
+
310
+ const itineraryItems: ItineraryDisplayStep[] = [];
311
+ const destinationMap = new Map(product.destinations.map((destination) => [destination.name, destination]));
312
+ const pickupOffsetMinutes = pickupLocationSkipped
313
+ ? 0
314
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
315
+ const actualPickupTime =
316
+ pickupOffsetMinutes > 0
317
+ ? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
318
+ : tourStartTime;
319
+ const pickupTimeDisplay =
320
+ pickupLocationSkipped && maxTimeOffsetMinutes > 0
321
+ ? `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(
322
+ new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000),
323
+ companyTimezone,
324
+ 'h:mm a',
325
+ )}`
326
+ : formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
327
+ const pickupPlace =
328
+ pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
329
+
330
+ itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
331
+
332
+ let lastDepartureTime = tourStartTime;
333
+ itinerary.forEach((itineraryItem, index) => {
334
+ const destination = destinationMap.get(itineraryItem.destinationName);
335
+ if (!destination) return;
336
+ const arrivalTime = new Date(
337
+ lastDepartureTime.getTime() + itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000,
338
+ );
339
+ itineraryItems.push({
340
+ stepType: StepType.arrive,
341
+ time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
342
+ place: destination.name,
343
+ });
344
+ const departureTime = itineraryItem.durationHours
345
+ ? new Date(arrivalTime.getTime() + itineraryItem.durationHours * 60 * 60 * 1000)
346
+ : arrivalTime;
347
+ const hasMoreDestinations = index < itinerary.length - 1;
348
+ if (itineraryItem.durationHours && hasMoreDestinations) {
349
+ itineraryItems.push({
350
+ stepType: StepType.depart,
351
+ time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
352
+ place: destination.name,
353
+ });
354
+ }
355
+ lastDepartureTime = departureTime;
356
+ });
357
+
358
+ const returnDateTime = selectedReturnOption ? parseISO(selectedReturnOption.dateTime) : lastDepartureTime;
359
+ const lastDestination =
360
+ itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
361
+ const lastDestName =
362
+ itinerary.length > 0
363
+ ? destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null
364
+ : null;
365
+ const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
366
+ if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) {
367
+ return loc.travelMinutesFromDestination[lastDestName];
368
+ }
369
+ return 0;
370
+ };
371
+ const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
372
+ Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
373
+ const dropOffPlace =
374
+ pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
375
+
376
+ if (selectedReturnOption) {
377
+ itineraryItems.push({
378
+ stepType: StepType.depart,
379
+ time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
380
+ place: lastDestination?.name ?? 'the_destination',
381
+ });
382
+ } else if (lastDestination) {
383
+ itineraryItems.push({
384
+ stepType: StepType.depart,
385
+ time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
386
+ place: lastDestination.name,
387
+ });
388
+ }
389
+
390
+ if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
391
+ const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
392
+ const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
393
+ itineraryItems.push({
394
+ stepType: StepType.drop_off,
395
+ time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
396
+ place: dropOffPlace,
397
+ });
398
+ } else if (pickupLocationSkipped || !selectedPickupLocation) {
399
+ itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
400
+ } else if (product.pickupLocations?.length) {
401
+ const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
402
+ const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
403
+ const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
404
+ const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
405
+ itineraryItems.push({
406
+ stepType: StepType.drop_off,
407
+ time:
408
+ minOffset === maxOffset
409
+ ? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
410
+ : `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(
411
+ latestDropOff,
412
+ companyTimezone,
413
+ 'h:mm a',
414
+ )}`,
415
+ place: dropOffPlace,
416
+ });
417
+ } else {
418
+ itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
419
+ }
420
+
421
+ return itineraryItems;
422
+ }, [
423
+ activeOptions,
424
+ companyTimezone,
425
+ getEffectiveItinerary,
426
+ maxTimeOffsetMinutes,
427
+ pickupLocationSkipped,
428
+ product,
429
+ selectedAvailability,
430
+ selectedPickupLocation,
431
+ selectedReturnOption,
432
+ ]);
433
+
434
+ const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
435
+ const base = computeItineraryDisplay();
436
+ if (!base || base.length === 0) return base;
437
+ const pickupUnknown =
438
+ pickupLocationSkipped ||
439
+ (!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
440
+ if (!pickupUnknown) return base;
441
+ const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
442
+ if (!tourStartTime) return base;
443
+ const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
444
+ const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
445
+ const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
446
+ const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
447
+ const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
448
+ return base.map((step, index) =>
449
+ index === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
450
+ ? { ...step, time: pickupRangeTime }
451
+ : step,
452
+ );
453
+ }, [
454
+ companyTimezone,
455
+ computeItineraryDisplay,
456
+ maxTimeOffsetMinutes,
457
+ pickupLocationSkipped,
458
+ product.pickupLocations,
459
+ selectedAvailability,
460
+ selectedPickupLocation,
461
+ ]);
462
+
463
+ return {
464
+ selectedPickupLocation,
465
+ pickupTimes,
466
+ hasAnyMostPopular,
467
+ getPickupCutoffEligibleTimesForDate,
468
+ isPublicSelectableAvailability,
469
+ calculateStaySummary,
470
+ computeItineraryDisplay,
471
+ computeItineraryDisplayForStorage,
472
+ };
473
+ }