@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.
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 +651 -6202
  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,806 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type Dispatch,
8
+ type SetStateAction,
9
+ } from 'react';
10
+ import { isAfter, isBefore, parseISO } from 'date-fns';
11
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
12
+
13
+ import {
14
+ getAvailabilities,
15
+ type Availability,
16
+ type PrecomputedPricesByCategory,
17
+ type PricingConfig,
18
+ type Product,
19
+ type ReturnOption,
20
+ } from '../../lib/booking-api';
21
+ import { filterAvailabilitiesAfterPublicBookingCutoff } from '../../lib/booking/booking-cutoffs';
22
+ import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
23
+ import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
24
+ import {
25
+ availabilityMatchesSelectedDate,
26
+ availabilityWithOptionId,
27
+ deriveDefaultQuantitiesFromAvailabilities,
28
+ findMergedAvailabilityForSelection,
29
+ getAvailabilityCacheKey,
30
+ getAvailabilityOptionId,
31
+ getInitialAvailabilityRangeEnd,
32
+ getInitialAvailabilityRangeStart,
33
+ getStartOfCurrentDay,
34
+ mergeAvailabilitiesIntoMap,
35
+ mergePrecomputedPricesFromAvailabilityResult,
36
+ parseAvailabilityDateTime,
37
+ shouldSyncSelectedAvailability,
38
+ } from './standard-booking-availability';
39
+
40
+ interface UseStandardBookingAvailabilityParams {
41
+ product: Product;
42
+ activeOptions: Product['options'];
43
+ activeOptionIdsKey: string;
44
+ isPrivateShuttle: boolean;
45
+ isPartialLaunch: boolean;
46
+ isAdmin: boolean;
47
+ companyTimezone: string;
48
+ appliedPromoCode: string | null;
49
+ pricingProfileIdForAvailabilities: string | null;
50
+ cancellationPolicyProfileIdForAvailabilities: string | null;
51
+ bookingCutoffNow: Date;
52
+ bookingCutoffMinutes?: number | null;
53
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
54
+ setError: Dispatch<SetStateAction<string>>;
55
+ }
56
+
57
+ type DateRange = { start: Date; end: Date };
58
+
59
+ function formatAvailabilityRequestRange(
60
+ start: Date,
61
+ end: Date,
62
+ isPrivateShuttle: boolean,
63
+ companyTimezone: string,
64
+ ): { startDateStr: string; endDateStr: string } {
65
+ if (isPrivateShuttle) {
66
+ return {
67
+ startDateStr: formatInTimeZone(start, companyTimezone, 'yyyy-MM-dd'),
68
+ endDateStr: formatInTimeZone(end, companyTimezone, 'yyyy-MM-dd'),
69
+ };
70
+ }
71
+
72
+ const startDateInTz = formatInTimeZone(start, companyTimezone, 'yyyy-MM-dd');
73
+ const endDateInTz = formatInTimeZone(end, companyTimezone, 'yyyy-MM-dd');
74
+ const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
75
+ const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
76
+ return {
77
+ startDateStr: formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
78
+ endDateStr: formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
79
+ };
80
+ }
81
+
82
+ function formatSelectedDateRequestRange(
83
+ selectedDate: string,
84
+ isPrivateShuttle: boolean,
85
+ companyTimezone: string,
86
+ ): { startDateStr: string; endDateStr: string } {
87
+ if (isPrivateShuttle) {
88
+ return { startDateStr: selectedDate, endDateStr: selectedDate };
89
+ }
90
+
91
+ const startMoment = fromZonedTime(parseISO(`${selectedDate}T00:00:00.000`), companyTimezone);
92
+ const endMoment = fromZonedTime(parseISO(`${selectedDate}T23:59:59.999`), companyTimezone);
93
+ return {
94
+ startDateStr: formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
95
+ endDateStr: formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
96
+ };
97
+ }
98
+
99
+ function clampAvailabilityRange(
100
+ range: DateRange,
101
+ companyTimezone: string,
102
+ extensionDate?: string,
103
+ ): DateRange {
104
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
105
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
106
+ const startOfCurrentDay = getStartOfCurrentDay(companyTimezone);
107
+ const minimumStart = isAfter(startOfCurrentDay, startOfEarliestDay) ? startOfCurrentDay : startOfEarliestDay;
108
+ const clampedStart = isBefore(range.start, minimumStart) ? minimumStart : range.start;
109
+ let clampedEnd = isAfter(range.end, endOfLatestDay) ? endOfLatestDay : range.end;
110
+ if (isBefore(clampedEnd, clampedStart)) {
111
+ clampedEnd = clampedStart;
112
+ }
113
+
114
+ if (extensionDate) {
115
+ try {
116
+ const selectedDateObj = parseISO(extensionDate);
117
+ if (isAfter(selectedDateObj, clampedEnd)) {
118
+ clampedEnd = selectedDateObj;
119
+ }
120
+ } catch {
121
+ // Ignore parse errors.
122
+ }
123
+ }
124
+
125
+ return { start: clampedStart, end: clampedEnd };
126
+ }
127
+
128
+ function mergeFetchedRanges(ranges: DateRange[]): DateRange[] {
129
+ const sorted = [...ranges].sort((a, b) => a.start.getTime() - b.start.getTime());
130
+ const merged: DateRange[] = [];
131
+ for (const range of sorted) {
132
+ if (merged.length === 0 || merged[merged.length - 1].end < range.start) {
133
+ merged.push({ start: range.start, end: range.end });
134
+ } else {
135
+ merged[merged.length - 1].end =
136
+ range.end > merged[merged.length - 1].end ? range.end : merged[merged.length - 1].end;
137
+ }
138
+ }
139
+ return merged;
140
+ }
141
+
142
+ export function useStandardBookingAvailability({
143
+ product,
144
+ activeOptions,
145
+ activeOptionIdsKey,
146
+ isPrivateShuttle,
147
+ isPartialLaunch,
148
+ isAdmin,
149
+ companyTimezone,
150
+ appliedPromoCode,
151
+ pricingProfileIdForAvailabilities,
152
+ cancellationPolicyProfileIdForAvailabilities,
153
+ bookingCutoffNow,
154
+ bookingCutoffMinutes,
155
+ setQuantities,
156
+ setError,
157
+ }: UseStandardBookingAvailabilityParams) {
158
+ const availabilitiesCache = useAvailabilitiesCache();
159
+ const [availabilities, setAvailabilities] = useState<Availability[]>([]);
160
+ const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
161
+ const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
162
+ const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
163
+ const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
164
+ const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
165
+ const [precomputedPricesByOption, setPrecomputedPricesByOption] =
166
+ useState<Record<string, PrecomputedPricesByCategory> | null>(null);
167
+ const [visibleRange, setVisibleRange] = useState<DateRange | null>(null);
168
+ const [selectedDate, setSelectedDate] = useState('');
169
+ const pricingConfigSetRef = useRef(false);
170
+ const fetchingRef = useRef(false);
171
+ const hasLoadedAvailabilitiesRef = useRef(false);
172
+ const inFlightRangeRef = useRef<DateRange | null>(null);
173
+ const fetchedRangesRef = useRef<DateRange[]>([]);
174
+ const pendingRangeRef = useRef<DateRange | null>(null);
175
+ const selectedDateHydrationInFlightKeyRef = useRef<string | null>(null);
176
+ const lastVisibleRangeRef = useRef<DateRange | null>(null);
177
+ const activeOptionsLength = activeOptions.length;
178
+
179
+ const applyPricingConfig = useCallback((next?: PricingConfig | null) => {
180
+ setPricingConfig((prev) => {
181
+ if (!shouldReplacePricingConfig(prev, next)) return prev;
182
+ pricingConfigSetRef.current = true;
183
+ return next ?? null;
184
+ });
185
+ }, []);
186
+
187
+ const applyCachedPricingConfig = useCallback((next: PricingConfig) => {
188
+ setPricingConfig(next);
189
+ pricingConfigSetRef.current = true;
190
+ }, []);
191
+
192
+ const selectedDateRangeExtensionKey = useMemo(() => {
193
+ if (!visibleRange || !selectedDate) return '';
194
+ try {
195
+ return isAfter(parseISO(selectedDate), visibleRange.end) ? selectedDate : '';
196
+ } catch {
197
+ return '';
198
+ }
199
+ }, [selectedDate, visibleRange]);
200
+
201
+ const needsFetch = useCallback((start: Date, end: Date): boolean => {
202
+ if (fetchedRangesRef.current.length === 0) return true;
203
+ return !fetchedRangesRef.current.some((range) => {
204
+ const rangeStart = range.start.getTime();
205
+ const rangeEnd = range.end.getTime();
206
+ const reqStart = start.getTime();
207
+ const reqEnd = end.getTime();
208
+ return rangeStart <= reqStart && rangeEnd >= reqEnd;
209
+ });
210
+ }, []);
211
+
212
+ const getCacheKey = useCallback(
213
+ () =>
214
+ availabilitiesCache
215
+ ? buildAvailabilitiesCacheKey(
216
+ product.productId,
217
+ activeOptionIdsKey,
218
+ appliedPromoCode,
219
+ pricingProfileIdForAvailabilities,
220
+ cancellationPolicyProfileIdForAvailabilities,
221
+ )
222
+ : null,
223
+ [
224
+ activeOptionIdsKey,
225
+ appliedPromoCode,
226
+ availabilitiesCache,
227
+ cancellationPolicyProfileIdForAvailabilities,
228
+ pricingProfileIdForAvailabilities,
229
+ product.productId,
230
+ ],
231
+ );
232
+
233
+ const mergeFetchedAvailabilities = useCallback(
234
+ (fetchedAvailabilities: Availability[]) => {
235
+ let mergedOut: Availability[] = [];
236
+ setAvailabilities((prev) => {
237
+ const existingMap = new Map(prev.map((availability) => [getAvailabilityCacheKey(availability), availability]));
238
+ mergeAvailabilitiesIntoMap(existingMap, fetchedAvailabilities);
239
+ mergedOut = Array.from(existingMap.values());
240
+ return mergedOut;
241
+ });
242
+ return mergedOut;
243
+ },
244
+ [],
245
+ );
246
+
247
+ const mergePrecomputedPrices = useCallback(
248
+ (results: Array<Parameters<typeof mergePrecomputedPricesFromAvailabilityResult>[1]>) => {
249
+ setPrecomputedPricesByOption((prev) => {
250
+ const next = { ...(prev || {}) };
251
+ results.forEach((result) => mergePrecomputedPricesFromAvailabilityResult(next, result));
252
+ return Object.keys(next).length > 0 ? next : prev;
253
+ });
254
+ },
255
+ [],
256
+ );
257
+
258
+ const updateCache = useCallback(
259
+ (
260
+ fetchedAvailabilities: Availability[],
261
+ results: Array<Parameters<typeof mergePrecomputedPricesFromAvailabilityResult>[1]>,
262
+ fetchedRanges?: DateRange[],
263
+ pricingConfigOverride?: PricingConfig | null,
264
+ ) => {
265
+ const cacheKey = getCacheKey();
266
+ if (!cacheKey || !availabilitiesCache) return;
267
+
268
+ const existingCache = availabilitiesCache.get(cacheKey);
269
+ const existingAvailabilities = existingCache?.availabilities ?? [];
270
+ const mergedAvailabilitiesMap = new Map(
271
+ existingAvailabilities.map((availability) => [getAvailabilityCacheKey(availability), availability]),
272
+ );
273
+ mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, fetchedAvailabilities);
274
+
275
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
276
+ results.forEach((result) => mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, result));
277
+ const firstPricingConfig =
278
+ pricingConfigOverride ??
279
+ results.find((result) => result.pricingConfig)?.pricingConfig ??
280
+ existingCache?.pricingConfig ??
281
+ null;
282
+
283
+ availabilitiesCache.merge(cacheKey, {
284
+ fetchedRanges: fetchedRanges ?? existingCache?.fetchedRanges ?? fetchedRangesRef.current,
285
+ availabilities: Array.from(mergedAvailabilitiesMap.values()),
286
+ pricingConfig: firstPricingConfig,
287
+ precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
288
+ });
289
+ },
290
+ [availabilitiesCache, getCacheKey],
291
+ );
292
+
293
+ const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
294
+ if (isPartialLaunch || !visibleRange || activeOptionsLength === 0) {
295
+ return [];
296
+ }
297
+
298
+ const { start, end } = clampAvailabilityRange(visibleRange, companyTimezone, selectedDate || undefined);
299
+ const { startDateStr, endDateStr } = formatAvailabilityRequestRange(
300
+ start,
301
+ end,
302
+ isPrivateShuttle,
303
+ companyTimezone,
304
+ );
305
+
306
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
307
+ allOptions: true,
308
+ promoCode: appliedPromoCode || undefined,
309
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
310
+ ...(cancellationPolicyProfileIdForAvailabilities
311
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
312
+ : {}),
313
+ });
314
+ applyPricingConfig(result.pricingConfig);
315
+
316
+ const results = [{
317
+ optionId: product.productId,
318
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
319
+ precomputedPrices: result.precomputedPrices,
320
+ precomputedPricesByOption: result.precomputedPricesByOption,
321
+ pricingConfig: result.pricingConfig,
322
+ }];
323
+ const allFetchedAvailabilities = results.flatMap((fetchResult) => fetchResult.availabilities);
324
+
325
+ mergePrecomputedPrices(results);
326
+ const mergedOut = mergeFetchedAvailabilities(allFetchedAvailabilities);
327
+
328
+ const mergedRanges = mergeFetchedRanges([
329
+ ...fetchedRangesRef.current,
330
+ { start: new Date(start), end: new Date(end) },
331
+ ]);
332
+ fetchedRangesRef.current = mergedRanges;
333
+ updateCache(allFetchedAvailabilities, results, mergedRanges);
334
+
335
+ return mergedOut;
336
+ }, [
337
+ activeOptionsLength,
338
+ appliedPromoCode,
339
+ applyPricingConfig,
340
+ cancellationPolicyProfileIdForAvailabilities,
341
+ companyTimezone,
342
+ isPartialLaunch,
343
+ isPrivateShuttle,
344
+ mergeFetchedAvailabilities,
345
+ mergePrecomputedPrices,
346
+ pricingProfileIdForAvailabilities,
347
+ product.productId,
348
+ selectedDate,
349
+ updateCache,
350
+ visibleRange,
351
+ ]);
352
+
353
+ const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<{
354
+ availabilities: Availability[];
355
+ pricingConfig?: PricingConfig | null;
356
+ }> => {
357
+ if (!selectedDate || activeOptionsLength === 0) return { availabilities: [] };
358
+
359
+ const { startDateStr, endDateStr } = formatSelectedDateRequestRange(
360
+ selectedDate,
361
+ isPrivateShuttle,
362
+ companyTimezone,
363
+ );
364
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
365
+ allOptions: true,
366
+ promoCode: appliedPromoCode || undefined,
367
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
368
+ ...(cancellationPolicyProfileIdForAvailabilities
369
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
370
+ : {}),
371
+ });
372
+
373
+ const results = [{
374
+ optionId: product.productId,
375
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
376
+ precomputedPrices: result.precomputedPrices,
377
+ precomputedPricesByOption: result.precomputedPricesByOption,
378
+ pricingConfig: result.pricingConfig,
379
+ }];
380
+ const detailedAvailabilities = results.flatMap((fetchResult) => fetchResult.availabilities);
381
+ if (detailedAvailabilities.length === 0) {
382
+ return { availabilities: [], pricingConfig: result.pricingConfig };
383
+ }
384
+
385
+ applyPricingConfig(result.pricingConfig);
386
+ mergePrecomputedPrices(results);
387
+ mergeFetchedAvailabilities(detailedAvailabilities);
388
+ updateCache(detailedAvailabilities, results, undefined, result.pricingConfig);
389
+
390
+ return { availabilities: detailedAvailabilities, pricingConfig: result.pricingConfig };
391
+ }, [
392
+ activeOptionsLength,
393
+ appliedPromoCode,
394
+ applyPricingConfig,
395
+ cancellationPolicyProfileIdForAvailabilities,
396
+ companyTimezone,
397
+ isPrivateShuttle,
398
+ mergeFetchedAvailabilities,
399
+ mergePrecomputedPrices,
400
+ pricingProfileIdForAvailabilities,
401
+ product.productId,
402
+ selectedDate,
403
+ updateCache,
404
+ ]);
405
+
406
+ useEffect(() => {
407
+ if (!visibleRange) {
408
+ const initialStart = getInitialAvailabilityRangeStart(companyTimezone);
409
+ const initialEnd = getInitialAvailabilityRangeEnd(initialStart);
410
+ setVisibleRange({ start: initialStart, end: initialEnd });
411
+ }
412
+ }, [companyTimezone, visibleRange]);
413
+
414
+ useEffect(() => {
415
+ if (isPartialLaunch) {
416
+ setLoadingAvailabilities(false);
417
+ return;
418
+ }
419
+ if (activeOptionsLength === 0) {
420
+ setError('No active product options available');
421
+ setLoadingAvailabilities(false);
422
+ return;
423
+ }
424
+ if (!visibleRange) return;
425
+
426
+ async function fetchAvailabilities() {
427
+ if (!visibleRange) return;
428
+ if (fetchingRef.current) {
429
+ const inFlight = inFlightRangeRef.current;
430
+ if (
431
+ inFlight &&
432
+ inFlight.start.getTime() === visibleRange.start.getTime() &&
433
+ inFlight.end.getTime() === visibleRange.end.getTime()
434
+ ) {
435
+ return;
436
+ }
437
+ pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
438
+ return;
439
+ }
440
+
441
+ const { start: clampedStart, end: clampedEnd } = clampAvailabilityRange(
442
+ visibleRange,
443
+ companyTimezone,
444
+ selectedDateRangeExtensionKey || undefined,
445
+ );
446
+
447
+ const cacheKey = getCacheKey();
448
+ const cached = cacheKey ? availabilitiesCache?.get(cacheKey) : undefined;
449
+ let shouldRevalidateCachedRange = false;
450
+ if (cached && cached.availabilities.length > 0) {
451
+ const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
452
+ if (cachedInitialQuantities) {
453
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : cachedInitialQuantities));
454
+ }
455
+ const cacheCoversRange = cached.fetchedRanges.some(
456
+ (range) =>
457
+ range.start.getTime() <= clampedStart.getTime() &&
458
+ range.end.getTime() >= clampedEnd.getTime(),
459
+ );
460
+ const isStale = availabilitiesCache?.isStale(cached) ?? false;
461
+ if (cacheCoversRange) {
462
+ shouldRevalidateCachedRange = true;
463
+ setAvailabilities(cached.availabilities);
464
+ if (cached.availabilities.length > 0) {
465
+ hasLoadedAvailabilitiesRef.current = true;
466
+ }
467
+ if (cached.pricingConfig) {
468
+ applyCachedPricingConfig(cached.pricingConfig);
469
+ }
470
+ if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
471
+ setPrecomputedPricesByOption(cached.precomputedPricesByOption);
472
+ }
473
+ setLoadingAvailabilities(false);
474
+ setIsFetchingMoreAvailabilities(false);
475
+ if (!isStale) {
476
+ fetchedRangesRef.current = [...cached.fetchedRanges];
477
+ }
478
+ }
479
+
480
+ setAvailabilities(cached.availabilities);
481
+ if (cached.availabilities.length > 0) {
482
+ hasLoadedAvailabilitiesRef.current = true;
483
+ }
484
+ if (cached.pricingConfig) {
485
+ applyCachedPricingConfig(cached.pricingConfig);
486
+ }
487
+ if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
488
+ setPrecomputedPricesByOption(cached.precomputedPricesByOption);
489
+ }
490
+ fetchedRangesRef.current = [...cached.fetchedRanges];
491
+ setLoadingAvailabilities(false);
492
+ }
493
+
494
+ const shouldFetch = shouldRevalidateCachedRange || needsFetch(clampedStart, clampedEnd);
495
+ if (!shouldFetch) {
496
+ setLoadingAvailabilities(false);
497
+ setIsFetchingMoreAvailabilities(false);
498
+ fetchingRef.current = false;
499
+ return;
500
+ }
501
+
502
+ const hasPartialCache = cached && cached.availabilities.length > 0;
503
+ const shouldUsePrimaryLoader = !hasPartialCache && !hasLoadedAvailabilitiesRef.current;
504
+ fetchingRef.current = true;
505
+ inFlightRangeRef.current = {
506
+ start: new Date(clampedStart),
507
+ end: new Date(clampedEnd),
508
+ };
509
+ if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
510
+ else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
511
+
512
+ try {
513
+ const { startDateStr, endDateStr } = formatAvailabilityRequestRange(
514
+ clampedStart,
515
+ clampedEnd,
516
+ isPrivateShuttle,
517
+ companyTimezone,
518
+ );
519
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
520
+ allOptions: true,
521
+ summary: true,
522
+ promoCode: appliedPromoCode || undefined,
523
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
524
+ ...(cancellationPolicyProfileIdForAvailabilities
525
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
526
+ : {}),
527
+ });
528
+ applyPricingConfig(result.pricingConfig);
529
+
530
+ const results = [{
531
+ optionId: product.productId,
532
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
533
+ precomputedPrices: result.precomputedPrices,
534
+ precomputedPricesByOption: result.precomputedPricesByOption,
535
+ pricingConfig: result.pricingConfig,
536
+ }];
537
+ const allFetchedAvailabilities = results.flatMap((fetchResult) => fetchResult.availabilities);
538
+ if (allFetchedAvailabilities.length > 0) {
539
+ hasLoadedAvailabilitiesRef.current = true;
540
+ }
541
+
542
+ mergePrecomputedPrices(results);
543
+ mergeFetchedAvailabilities(allFetchedAvailabilities);
544
+
545
+ const merged = mergeFetchedRanges([
546
+ ...fetchedRangesRef.current,
547
+ { start: new Date(clampedStart), end: new Date(clampedEnd) },
548
+ ]);
549
+ fetchedRangesRef.current = merged;
550
+ updateCache(allFetchedAvailabilities, results, merged);
551
+
552
+ const fetchedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(allFetchedAvailabilities);
553
+ if (fetchedInitialQuantities) {
554
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
555
+ }
556
+ } catch (err) {
557
+ if (shouldRevalidateCachedRange && cached?.availabilities.length) {
558
+ console.warn('Background availability refresh failed; keeping cached availability visible.', err);
559
+ } else {
560
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
561
+ console.error('Error fetching availabilities:', err);
562
+ }
563
+ } finally {
564
+ setLoadingAvailabilities(false);
565
+ setIsFetchingMoreAvailabilities(false);
566
+ fetchingRef.current = false;
567
+ inFlightRangeRef.current = null;
568
+ const pending = pendingRangeRef.current;
569
+ if (pending) {
570
+ pendingRangeRef.current = null;
571
+ setVisibleRange({ start: pending.start, end: pending.end });
572
+ }
573
+ }
574
+ }
575
+
576
+ fetchAvailabilities();
577
+ }, [
578
+ activeOptionIdsKey,
579
+ activeOptionsLength,
580
+ appliedPromoCode,
581
+ applyCachedPricingConfig,
582
+ applyPricingConfig,
583
+ availabilitiesCache,
584
+ cancellationPolicyProfileIdForAvailabilities,
585
+ companyTimezone,
586
+ getCacheKey,
587
+ isPartialLaunch,
588
+ isPrivateShuttle,
589
+ mergeFetchedAvailabilities,
590
+ mergePrecomputedPrices,
591
+ needsFetch,
592
+ pricingProfileIdForAvailabilities,
593
+ product.productId,
594
+ selectedDateRangeExtensionKey,
595
+ setError,
596
+ setQuantities,
597
+ updateCache,
598
+ visibleRange,
599
+ ]);
600
+
601
+ useEffect(() => {
602
+ fetchedRangesRef.current = [];
603
+ }, [
604
+ appliedPromoCode,
605
+ pricingProfileIdForAvailabilities,
606
+ cancellationPolicyProfileIdForAvailabilities,
607
+ ]);
608
+
609
+ const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
610
+ const lastRange = lastVisibleRangeRef.current;
611
+ const rangeChanged =
612
+ !lastRange ||
613
+ Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
614
+ Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
615
+
616
+ if (rangeChanged) {
617
+ lastVisibleRangeRef.current = { start, end };
618
+ setVisibleRange({ start, end });
619
+ }
620
+ }, []);
621
+
622
+ const publicVisibleAvailabilities = useMemo(
623
+ () => {
624
+ const todayStr = formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd');
625
+ const currentOrFutureAvailabilities = availabilities.filter((availability) => {
626
+ const dateTime = parseAvailabilityDateTime(availability.dateTime);
627
+ return formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd') >= todayStr;
628
+ });
629
+
630
+ return isAdmin
631
+ ? currentOrFutureAvailabilities
632
+ : filterAvailabilitiesAfterPublicBookingCutoff(
633
+ currentOrFutureAvailabilities,
634
+ bookingCutoffNow,
635
+ bookingCutoffMinutes,
636
+ );
637
+ },
638
+ [availabilities, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
639
+ );
640
+
641
+ const availabilitiesByDate = useMemo(() => {
642
+ const grouped = publicVisibleAvailabilities.reduce((acc, availability) => {
643
+ const dateTime = parseAvailabilityDateTime(availability.dateTime);
644
+ const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
645
+ if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
646
+ acc[dateInCompanyTz].push(availability);
647
+ return acc;
648
+ }, {} as Record<string, Availability[]>);
649
+
650
+ const sorted: Record<string, Availability[]> = {};
651
+ Object.keys(grouped).forEach((date) => {
652
+ sorted[date] = [...grouped[date]].sort((a, b) => {
653
+ const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
654
+ const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
655
+ return timeA - timeB;
656
+ });
657
+ });
658
+
659
+ return sorted;
660
+ }, [companyTimezone, publicVisibleAvailabilities]);
661
+
662
+ const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
663
+
664
+ useEffect(() => {
665
+ if (!selectedDate || activeOptionsLength === 0) return;
666
+ if (!selectedAvailability) return;
667
+ if (!availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)) return;
668
+ if (selectedAvailability.isSummary !== true) return;
669
+ const selectionToHydrate = selectedAvailability;
670
+
671
+ const hydrationKey = [
672
+ product.productId,
673
+ selectedDate,
674
+ activeOptionIdsKey,
675
+ appliedPromoCode ?? '',
676
+ pricingProfileIdForAvailabilities ?? '',
677
+ cancellationPolicyProfileIdForAvailabilities ?? '',
678
+ ].join('|');
679
+ if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) return;
680
+ selectedDateHydrationInFlightKeyRef.current = hydrationKey;
681
+
682
+ let cancelled = false;
683
+ async function hydrateSelectedDateDetails() {
684
+ setIsFetchingMoreAvailabilities(true);
685
+ try {
686
+ const { startDateStr, endDateStr } = formatSelectedDateRequestRange(
687
+ selectedDate,
688
+ isPrivateShuttle,
689
+ companyTimezone,
690
+ );
691
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
692
+ allOptions: true,
693
+ responseMode: 'booking-day',
694
+ promoCode: appliedPromoCode || undefined,
695
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
696
+ ...(cancellationPolicyProfileIdForAvailabilities
697
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
698
+ : {}),
699
+ });
700
+ const results = [{
701
+ optionId: product.productId,
702
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
703
+ precomputedPrices: result.precomputedPrices,
704
+ precomputedPricesByOption: result.precomputedPricesByOption,
705
+ pricingConfig: result.pricingConfig,
706
+ }];
707
+ if (cancelled) return;
708
+
709
+ const detailedAvailabilities = results.flatMap((fetchResult) => fetchResult.availabilities);
710
+ if (detailedAvailabilities.length === 0) return;
711
+
712
+ const firstPricingConfig = results.find((fetchResult) => fetchResult.pricingConfig)?.pricingConfig;
713
+ applyPricingConfig(firstPricingConfig);
714
+ mergePrecomputedPrices(results);
715
+ mergeFetchedAvailabilities(detailedAvailabilities);
716
+
717
+ const updatedSelection = findMergedAvailabilityForSelection(detailedAvailabilities, selectionToHydrate);
718
+ if (updatedSelection && shouldSyncSelectedAvailability(selectionToHydrate, updatedSelection)) {
719
+ setSelectedAvailability(updatedSelection);
720
+ if (selectedReturnOption && updatedSelection.returnOptions) {
721
+ const updatedReturnOption = updatedSelection.returnOptions.find(
722
+ (option) => option.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
723
+ );
724
+ if (updatedReturnOption) setSelectedReturnOption(updatedReturnOption);
725
+ }
726
+ }
727
+
728
+ updateCache(detailedAvailabilities, results, undefined, firstPricingConfig);
729
+ } catch (err) {
730
+ if (!cancelled) {
731
+ console.error('Error hydrating availability details:', err);
732
+ }
733
+ } finally {
734
+ if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) {
735
+ selectedDateHydrationInFlightKeyRef.current = null;
736
+ }
737
+ if (!cancelled) setIsFetchingMoreAvailabilities(false);
738
+ }
739
+ }
740
+
741
+ hydrateSelectedDateDetails();
742
+ return () => {
743
+ cancelled = true;
744
+ };
745
+ }, [
746
+ activeOptionIdsKey,
747
+ activeOptionsLength,
748
+ appliedPromoCode,
749
+ applyPricingConfig,
750
+ cancellationPolicyProfileIdForAvailabilities,
751
+ companyTimezone,
752
+ isPrivateShuttle,
753
+ mergeFetchedAvailabilities,
754
+ mergePrecomputedPrices,
755
+ pricingProfileIdForAvailabilities,
756
+ product.productId,
757
+ selectedAvailability,
758
+ selectedDate,
759
+ selectedReturnOption,
760
+ updateCache,
761
+ ]);
762
+
763
+ useEffect(() => {
764
+ if (selectedAvailability && availabilities.length > 0) {
765
+ const updatedAvailability = availabilities.find(
766
+ (availability) =>
767
+ availability.dateTime === selectedAvailability.dateTime &&
768
+ getAvailabilityOptionId(availability) === getAvailabilityOptionId(selectedAvailability),
769
+ );
770
+ if (updatedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedAvailability)) {
771
+ setSelectedAvailability(updatedAvailability);
772
+
773
+ if (selectedReturnOption && updatedAvailability.returnOptions) {
774
+ const updatedReturnOption = updatedAvailability.returnOptions.find(
775
+ (option) => option.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
776
+ );
777
+ if (updatedReturnOption) {
778
+ setSelectedReturnOption(updatedReturnOption);
779
+ }
780
+ }
781
+ }
782
+ }
783
+ // eslint-disable-next-line react-hooks/exhaustive-deps
784
+ }, [availabilities]);
785
+
786
+ return {
787
+ availabilities,
788
+ selectedAvailability,
789
+ setSelectedAvailability,
790
+ selectedReturnOption,
791
+ setSelectedReturnOption,
792
+ selectedDate,
793
+ setSelectedDate,
794
+ loadingAvailabilities,
795
+ isFetchingMoreAvailabilities,
796
+ pricingConfig,
797
+ precomputedPricesByOption,
798
+ publicVisibleAvailabilities,
799
+ availabilitiesByDate,
800
+ dates,
801
+ handleVisibleRangeChange,
802
+ reloadAvailabilitiesAfterReserveConflict,
803
+ refreshSelectedDateDetailsForCheckout,
804
+ fetchedRangesRef,
805
+ };
806
+ }