@ticketboothapp/booking 1.2.123 → 1.2.125

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 +155 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,602 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type Dispatch,
8
+ type SetStateAction,
9
+ } from 'react';
10
+ import { addWeeks, 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
+ } from '../../lib/booking-api';
20
+ import { isDateAfterPublicBookingCutoff } from '../../lib/booking/booking-cutoffs';
21
+ import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
22
+ import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
23
+ import {
24
+ EARLIEST_AVAILABILITY_DATE,
25
+ INITIAL_FETCH_WEEKS,
26
+ VISIBLE_RANGE_BUFFER_WEEKS,
27
+ } from '../../lib/booking-constants';
28
+ import {
29
+ getPrivateShuttleAvailabilityCacheKey,
30
+ getPrivateShuttleAvailabilityOptionId,
31
+ parseAvailabilityDateTime,
32
+ privateShuttleAvailabilityDateInZone,
33
+ privateShuttleStartTimeAllowed,
34
+ } from './private-shuttle-availability';
35
+ import { getStartOfCurrentDay } from './standard-booking-availability';
36
+
37
+ interface UsePrivateShuttleAvailabilityParams {
38
+ product: Product;
39
+ activeOptions: Product['options'];
40
+ activeOptionIdsKey: string;
41
+ selectedDate: string;
42
+ activePromoCode: string | null;
43
+ pricingProfileIdForAvailabilities: string | null;
44
+ cancellationPolicyProfileIdForAvailabilities: string | null;
45
+ companyTimezone: string;
46
+ isAdmin: boolean;
47
+ bookingCutoffNow: Date;
48
+ bookingCutoffMinutes?: number | null;
49
+ passengerCount: number;
50
+ setPassengerCount: Dispatch<SetStateAction<number>>;
51
+ setError: Dispatch<SetStateAction<string>>;
52
+ }
53
+
54
+ type DateRange = { start: Date; end: Date };
55
+
56
+ function clampAvailabilityRange(
57
+ range: DateRange,
58
+ selectedDate: string,
59
+ companyTimezone: string,
60
+ ): DateRange {
61
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
62
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
63
+ const startOfCurrentDay = getStartOfCurrentDay(companyTimezone);
64
+ const minimumStart = isAfter(startOfCurrentDay, startOfEarliestDay) ? startOfCurrentDay : startOfEarliestDay;
65
+ const clampedStart = isBefore(range.start, minimumStart) ? minimumStart : range.start;
66
+ let clampedEnd = isAfter(range.end, endOfLatestDay) ? endOfLatestDay : range.end;
67
+ if (isBefore(clampedEnd, clampedStart)) {
68
+ clampedEnd = clampedStart;
69
+ }
70
+
71
+ if (selectedDate) {
72
+ try {
73
+ const selectedDateObj = parseISO(selectedDate);
74
+ if (isAfter(selectedDateObj, clampedEnd)) clampedEnd = selectedDateObj;
75
+ } catch {
76
+ // Ignore parse errors.
77
+ }
78
+ }
79
+
80
+ return { start: clampedStart, end: clampedEnd };
81
+ }
82
+
83
+ function mergeFetchedRanges(ranges: DateRange[]): DateRange[] {
84
+ const sorted = [...ranges].sort((a, b) => a.start.getTime() - b.start.getTime());
85
+ const merged: DateRange[] = [];
86
+ for (const range of sorted) {
87
+ if (merged.length === 0 || merged[merged.length - 1].end < range.start) {
88
+ merged.push({ start: range.start, end: range.end });
89
+ } else {
90
+ merged[merged.length - 1].end =
91
+ range.end > merged[merged.length - 1].end ? range.end : merged[merged.length - 1].end;
92
+ }
93
+ }
94
+ return merged;
95
+ }
96
+
97
+ function mergePrivateShuttleAvailabilities(
98
+ existing: Availability[],
99
+ incoming: Availability[],
100
+ ): Availability[] {
101
+ if (existing.length === 0) return incoming;
102
+ const existingMap = new Map(
103
+ existing.map((availability) => [
104
+ getPrivateShuttleAvailabilityCacheKey(availability),
105
+ availability,
106
+ ]),
107
+ );
108
+ incoming.forEach((availability) => {
109
+ existingMap.set(getPrivateShuttleAvailabilityCacheKey(availability), availability);
110
+ });
111
+ return Array.from(existingMap.values());
112
+ }
113
+
114
+ export function usePrivateShuttleAvailability({
115
+ product,
116
+ activeOptions,
117
+ activeOptionIdsKey,
118
+ selectedDate,
119
+ activePromoCode,
120
+ pricingProfileIdForAvailabilities,
121
+ cancellationPolicyProfileIdForAvailabilities,
122
+ companyTimezone,
123
+ isAdmin,
124
+ bookingCutoffNow,
125
+ bookingCutoffMinutes,
126
+ passengerCount,
127
+ setPassengerCount,
128
+ setError,
129
+ }: UsePrivateShuttleAvailabilityParams) {
130
+ const availabilitiesCache = useAvailabilitiesCache();
131
+ const [availabilities, setAvailabilities] = useState<Availability[]>([]);
132
+ const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
133
+ const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
134
+ const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
135
+ const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
136
+ const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
137
+ const [resourcePriceByOption, setResourcePriceByOption] =
138
+ useState<Record<string, Record<string, number>> | null>(null);
139
+ const [visibleRange, setVisibleRange] = useState<DateRange | null>(null);
140
+ const fetchingRef = useRef(false);
141
+ const fetchedRangesRef = useRef<DateRange[]>([]);
142
+ const pricingConfigSetRef = useRef(false);
143
+ const lastVisibleRangeRef = useRef<DateRange | null>(null);
144
+
145
+ const applyPricingConfig = useCallback((next?: PricingConfig | null) => {
146
+ setPricingConfig((prev) => {
147
+ if (!shouldReplacePricingConfig(prev, next)) return prev;
148
+ pricingConfigSetRef.current = true;
149
+ return next ?? null;
150
+ });
151
+ }, []);
152
+
153
+ const applyCachedPricingConfig = useCallback((next: PricingConfig) => {
154
+ setPricingConfig(next);
155
+ pricingConfigSetRef.current = true;
156
+ }, []);
157
+
158
+ const getCacheKey = useCallback(
159
+ () =>
160
+ availabilitiesCache
161
+ ? buildAvailabilitiesCacheKey(
162
+ product.productId,
163
+ activeOptionIdsKey,
164
+ activePromoCode,
165
+ pricingProfileIdForAvailabilities,
166
+ )
167
+ : null,
168
+ [
169
+ activeOptionIdsKey,
170
+ activePromoCode,
171
+ availabilitiesCache,
172
+ pricingProfileIdForAvailabilities,
173
+ product.productId,
174
+ ],
175
+ );
176
+
177
+ const applyAvailabilityPriceFields = useCallback(
178
+ (result: {
179
+ pricingConfig?: PricingConfig | null;
180
+ precomputedPrices?: PrecomputedPricesByCategory | null;
181
+ resourcePriceByCurrency?: Record<string, number> | null;
182
+ resourcePriceByOption?: Record<string, Record<string, number>> | null;
183
+ }) => {
184
+ applyPricingConfig(result.pricingConfig);
185
+ if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
186
+ if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
187
+ if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
188
+ },
189
+ [applyPricingConfig],
190
+ );
191
+
192
+ const mergeIntoState = useCallback((incoming: Availability[]) => {
193
+ let mergedAvailabilities: Availability[] = [];
194
+ setAvailabilities((prev) => {
195
+ mergedAvailabilities = mergePrivateShuttleAvailabilities(prev, incoming);
196
+ return mergedAvailabilities;
197
+ });
198
+ return mergedAvailabilities;
199
+ }, []);
200
+
201
+ const updateCache = useCallback(
202
+ (
203
+ incoming: Availability[],
204
+ result: {
205
+ pricingConfig?: PricingConfig | null;
206
+ precomputedPrices?: PrecomputedPricesByCategory | null;
207
+ resourcePriceByCurrency?: Record<string, number> | null;
208
+ resourcePriceByOption?: Record<string, Record<string, number>> | null;
209
+ },
210
+ fetchedRanges?: DateRange[],
211
+ ) => {
212
+ const cacheKey = getCacheKey();
213
+ if (!cacheKey || !availabilitiesCache) return;
214
+
215
+ const existingCache = availabilitiesCache.get(cacheKey);
216
+ const mergedAvailabilities = mergePrivateShuttleAvailabilities(
217
+ existingCache?.availabilities ?? [],
218
+ incoming,
219
+ );
220
+ availabilitiesCache.merge(cacheKey, {
221
+ fetchedRanges: fetchedRanges ?? existingCache?.fetchedRanges ?? fetchedRangesRef.current,
222
+ availabilities: mergedAvailabilities,
223
+ pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
224
+ precomputedPrices: result.precomputedPrices ?? existingCache?.precomputedPrices ?? null,
225
+ resourcePriceByCurrency:
226
+ result.resourcePriceByCurrency ?? existingCache?.resourcePriceByCurrency ?? null,
227
+ resourcePriceByOption: result.resourcePriceByOption ?? existingCache?.resourcePriceByOption ?? null,
228
+ });
229
+ },
230
+ [availabilitiesCache, getCacheKey],
231
+ );
232
+
233
+ const needsFetch = useCallback((start: Date, end: Date) => {
234
+ if (fetchedRangesRef.current.length === 0) return true;
235
+ return !fetchedRangesRef.current.some(
236
+ (range) => range.start.getTime() <= start.getTime() && range.end.getTime() >= end.getTime(),
237
+ );
238
+ }, []);
239
+
240
+ const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
241
+ if (!visibleRange || !product.productId) return [];
242
+
243
+ const { start, end } = clampAvailabilityRange(visibleRange, selectedDate, companyTimezone);
244
+ const startDate = formatInTimeZone(start, companyTimezone, 'yyyy-MM-dd');
245
+ const endDate = formatInTimeZone(end, companyTimezone, 'yyyy-MM-dd');
246
+ const result = await getAvailabilities(product.productId, startDate, endDate, {
247
+ allOptions: true,
248
+ promoCode: activePromoCode || undefined,
249
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
250
+ ...(cancellationPolicyProfileIdForAvailabilities
251
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
252
+ : {}),
253
+ });
254
+
255
+ applyAvailabilityPriceFields(result);
256
+ const mergedAvailabilities = mergeIntoState(result.availabilities);
257
+ const mergedRanges = mergeFetchedRanges([
258
+ ...fetchedRangesRef.current,
259
+ { start: new Date(start), end: new Date(end) },
260
+ ]);
261
+ fetchedRangesRef.current = mergedRanges;
262
+ updateCache(result.availabilities, result, mergedRanges);
263
+
264
+ return mergedAvailabilities;
265
+ }, [
266
+ activePromoCode,
267
+ applyAvailabilityPriceFields,
268
+ cancellationPolicyProfileIdForAvailabilities,
269
+ companyTimezone,
270
+ mergeIntoState,
271
+ pricingProfileIdForAvailabilities,
272
+ product.productId,
273
+ selectedDate,
274
+ updateCache,
275
+ visibleRange,
276
+ ]);
277
+
278
+ const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<{
279
+ availabilities: Availability[];
280
+ pricingConfig?: PricingConfig | null;
281
+ }> => {
282
+ if (!selectedDate || !product.productId) return { availabilities: [] };
283
+
284
+ const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
285
+ allOptions: true,
286
+ promoCode: activePromoCode || undefined,
287
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
288
+ ...(cancellationPolicyProfileIdForAvailabilities
289
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
290
+ : {}),
291
+ });
292
+
293
+ applyAvailabilityPriceFields(result);
294
+ mergeIntoState(result.availabilities);
295
+ updateCache(result.availabilities, result);
296
+
297
+ return { availabilities: result.availabilities, pricingConfig: result.pricingConfig };
298
+ }, [
299
+ activePromoCode,
300
+ applyAvailabilityPriceFields,
301
+ cancellationPolicyProfileIdForAvailabilities,
302
+ mergeIntoState,
303
+ pricingProfileIdForAvailabilities,
304
+ product.productId,
305
+ selectedDate,
306
+ updateCache,
307
+ ]);
308
+
309
+ useEffect(() => {
310
+ if (!visibleRange) {
311
+ setVisibleRange({
312
+ start: EARLIEST_AVAILABILITY_DATE,
313
+ end: addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS + VISIBLE_RANGE_BUFFER_WEEKS),
314
+ });
315
+ }
316
+ }, [visibleRange]);
317
+
318
+ useEffect(() => {
319
+ fetchedRangesRef.current = [];
320
+ }, [
321
+ activePromoCode,
322
+ pricingProfileIdForAvailabilities,
323
+ cancellationPolicyProfileIdForAvailabilities,
324
+ ]);
325
+
326
+ const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
327
+ const lastRange = lastVisibleRangeRef.current;
328
+ const rangeChanged =
329
+ !lastRange ||
330
+ Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
331
+ Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
332
+ if (rangeChanged) {
333
+ lastVisibleRangeRef.current = { start, end };
334
+ setVisibleRange({ start, end });
335
+ }
336
+ }, []);
337
+
338
+ useEffect(() => {
339
+ if (activeOptions.length === 0) {
340
+ setError('No active product options available');
341
+ setLoadingAvailabilities(false);
342
+ return;
343
+ }
344
+ if (!visibleRange) return;
345
+
346
+ async function fetchAvailabilities() {
347
+ if (!visibleRange) return;
348
+ if (fetchingRef.current) return;
349
+
350
+ const { start: clampedStart, end: clampedEnd } = clampAvailabilityRange(
351
+ visibleRange,
352
+ selectedDate,
353
+ companyTimezone,
354
+ );
355
+ const cacheKey = getCacheKey();
356
+ const cached = cacheKey ? availabilitiesCache?.get(cacheKey) : undefined;
357
+ let shouldRevalidateCachedRange = false;
358
+
359
+ if (cached && cached.availabilities.length > 0) {
360
+ const cacheCoversRange = cached.fetchedRanges.some(
361
+ (range) =>
362
+ range.start.getTime() <= clampedStart.getTime() &&
363
+ range.end.getTime() >= clampedEnd.getTime(),
364
+ );
365
+ const isStale = availabilitiesCache?.isStale(cached) ?? false;
366
+ if (cacheCoversRange) {
367
+ shouldRevalidateCachedRange = true;
368
+ setAvailabilities(cached.availabilities);
369
+ if (cached.pricingConfig) applyCachedPricingConfig(cached.pricingConfig);
370
+ if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
371
+ if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
372
+ if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
373
+ setLoadingAvailabilities(false);
374
+ setIsFetchingMoreAvailabilities(false);
375
+ if (!isStale) {
376
+ fetchedRangesRef.current = [...cached.fetchedRanges];
377
+ }
378
+ }
379
+
380
+ setAvailabilities(cached.availabilities);
381
+ if (cached.pricingConfig) applyCachedPricingConfig(cached.pricingConfig);
382
+ if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
383
+ if (cached.resourcePriceByCurrency) setResourcePriceByCurrency(cached.resourcePriceByCurrency);
384
+ if (cached.resourcePriceByOption) setResourcePriceByOption(cached.resourcePriceByOption);
385
+ fetchedRangesRef.current = [...cached.fetchedRanges];
386
+ setLoadingAvailabilities(false);
387
+ }
388
+
389
+ if (!shouldRevalidateCachedRange && !needsFetch(clampedStart, clampedEnd)) {
390
+ setLoadingAvailabilities(false);
391
+ setIsFetchingMoreAvailabilities(false);
392
+ fetchingRef.current = false;
393
+ return;
394
+ }
395
+
396
+ const hasPartialCache = cached && cached.availabilities.length > 0;
397
+ fetchingRef.current = true;
398
+ if (!hasPartialCache) setLoadingAvailabilities(true);
399
+ else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
400
+
401
+ try {
402
+ const startDate = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
403
+ const endDate = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
404
+ const result = await getAvailabilities(product.productId, startDate, endDate, {
405
+ allOptions: true,
406
+ summary: true,
407
+ promoCode: activePromoCode || undefined,
408
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
409
+ ...(cancellationPolicyProfileIdForAvailabilities
410
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
411
+ : {}),
412
+ });
413
+ applyAvailabilityPriceFields(result);
414
+
415
+ const mergedAvailabilities = mergePrivateShuttleAvailabilities(
416
+ hasPartialCache && cached ? cached.availabilities : [],
417
+ result.availabilities,
418
+ );
419
+ setAvailabilities(mergedAvailabilities);
420
+
421
+ const mergedRanges = mergeFetchedRanges([
422
+ ...fetchedRangesRef.current,
423
+ { start: new Date(clampedStart), end: new Date(clampedEnd) },
424
+ ]);
425
+ fetchedRangesRef.current = mergedRanges;
426
+ if (passengerCount === 0) setPassengerCount(1);
427
+ updateCache(mergedAvailabilities, result, mergedRanges);
428
+ } catch (err) {
429
+ if (shouldRevalidateCachedRange && cached?.availabilities.length) {
430
+ console.warn('Background private shuttle availability refresh failed; keeping cached availability visible.', err);
431
+ } else {
432
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
433
+ }
434
+ } finally {
435
+ setLoadingAvailabilities(false);
436
+ setIsFetchingMoreAvailabilities(false);
437
+ fetchingRef.current = false;
438
+ }
439
+ }
440
+
441
+ fetchAvailabilities();
442
+ }, [
443
+ activeOptions,
444
+ activePromoCode,
445
+ applyAvailabilityPriceFields,
446
+ applyCachedPricingConfig,
447
+ availabilitiesCache,
448
+ cancellationPolicyProfileIdForAvailabilities,
449
+ companyTimezone,
450
+ getCacheKey,
451
+ needsFetch,
452
+ passengerCount,
453
+ pricingProfileIdForAvailabilities,
454
+ product.productId,
455
+ selectedDate,
456
+ setError,
457
+ setPassengerCount,
458
+ updateCache,
459
+ visibleRange,
460
+ ]);
461
+
462
+ const isPrivateShuttleAvailabilityBookable = useCallback(
463
+ (availability: Availability): boolean => {
464
+ if (isAdmin) return true;
465
+ const dateInCompanyTz = privateShuttleAvailabilityDateInZone(availability, companyTimezone);
466
+ const optionId = getPrivateShuttleAvailabilityOptionId(availability);
467
+ const option = activeOptions.find((opt) => opt.optionId === optionId);
468
+ const startTimes = availability.suggestedStartTimes || option?.privateShuttleConfig?.suggestedStartTimes || [];
469
+ if (startTimes.length === 0) {
470
+ return isDateAfterPublicBookingCutoff(
471
+ fromZonedTime(parseISO(`${dateInCompanyTz}T23:59:59`), companyTimezone),
472
+ bookingCutoffNow,
473
+ bookingCutoffMinutes,
474
+ );
475
+ }
476
+ return startTimes.some((time) =>
477
+ privateShuttleStartTimeAllowed(
478
+ dateInCompanyTz,
479
+ time,
480
+ companyTimezone,
481
+ bookingCutoffNow,
482
+ bookingCutoffMinutes,
483
+ ),
484
+ );
485
+ },
486
+ [activeOptions, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
487
+ );
488
+
489
+ const publicVisibleAvailabilities = useMemo(
490
+ () => {
491
+ const todayStr = formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd');
492
+ const currentOrFutureAvailabilities = availabilities.filter(
493
+ (availability) => privateShuttleAvailabilityDateInZone(availability, companyTimezone) >= todayStr,
494
+ );
495
+ return isAdmin
496
+ ? currentOrFutureAvailabilities
497
+ : currentOrFutureAvailabilities.filter(isPrivateShuttleAvailabilityBookable);
498
+ },
499
+ [availabilities, companyTimezone, isAdmin, isPrivateShuttleAvailabilityBookable],
500
+ );
501
+
502
+ const availabilitiesByDate = useMemo(() => {
503
+ const grouped = publicVisibleAvailabilities.reduce(
504
+ (acc, availability) => {
505
+ const dateInCompanyTz = privateShuttleAvailabilityDateInZone(availability, companyTimezone);
506
+ if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
507
+ acc[dateInCompanyTz].push(availability);
508
+ return acc;
509
+ },
510
+ {} as Record<string, Availability[]>,
511
+ );
512
+
513
+ const sorted: Record<string, Availability[]> = {};
514
+ Object.keys(grouped).forEach((date) => {
515
+ sorted[date] = [...grouped[date]].sort((a, b) => {
516
+ const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
517
+ const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
518
+ return timeA - timeB;
519
+ });
520
+ });
521
+ return sorted;
522
+ }, [companyTimezone, publicVisibleAvailabilities]);
523
+
524
+ const optionsAvailableForSelectedDate = useMemo(() => {
525
+ if (!selectedDate) return [];
526
+ const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
527
+ const optionIds = new Set(dateAvailabilities.map(getPrivateShuttleAvailabilityOptionId).filter(Boolean));
528
+ return activeOptions.filter((option) => optionIds.has(option.optionId));
529
+ }, [activeOptions, availabilitiesByDate, selectedDate]);
530
+
531
+ const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
532
+
533
+ useEffect(() => {
534
+ if (!selectedDate || !product.productId) return;
535
+ const selectedDateAvailabilities = availabilitiesByDate[selectedDate] ?? [];
536
+ if (!selectedDateAvailabilities.some((availability) => availability.isSummary)) return;
537
+
538
+ let cancelled = false;
539
+ async function hydrateSelectedDateDetails() {
540
+ setIsFetchingMoreAvailabilities(true);
541
+ try {
542
+ const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
543
+ allOptions: true,
544
+ promoCode: activePromoCode || undefined,
545
+ ...(pricingProfileIdForAvailabilities ? { pricingProfileId: pricingProfileIdForAvailabilities } : {}),
546
+ ...(cancellationPolicyProfileIdForAvailabilities
547
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
548
+ : {}),
549
+ });
550
+ if (cancelled) return;
551
+
552
+ applyAvailabilityPriceFields(result);
553
+ mergeIntoState(result.availabilities);
554
+ updateCache(result.availabilities, result);
555
+ } catch (err) {
556
+ if (!cancelled) {
557
+ console.error('Error hydrating private shuttle availability details:', err);
558
+ }
559
+ } finally {
560
+ if (!cancelled) setIsFetchingMoreAvailabilities(false);
561
+ }
562
+ }
563
+
564
+ hydrateSelectedDateDetails();
565
+ return () => {
566
+ cancelled = true;
567
+ };
568
+ }, [
569
+ activePromoCode,
570
+ applyAvailabilityPriceFields,
571
+ availabilitiesByDate,
572
+ cancellationPolicyProfileIdForAvailabilities,
573
+ mergeIntoState,
574
+ pricingProfileIdForAvailabilities,
575
+ product.productId,
576
+ selectedDate,
577
+ updateCache,
578
+ ]);
579
+
580
+ const earliestAvailabilityDate = useMemo(() => {
581
+ if (dates.length === 0) return EARLIEST_AVAILABILITY_DATE;
582
+ return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
583
+ }, [companyTimezone, dates]);
584
+
585
+ return {
586
+ availabilities,
587
+ loadingAvailabilities,
588
+ isFetchingMoreAvailabilities,
589
+ pricingConfig,
590
+ precomputedPrices,
591
+ resourcePriceByCurrency,
592
+ resourcePriceByOption,
593
+ availabilitiesByDate,
594
+ optionsAvailableForSelectedDate,
595
+ dates,
596
+ earliestAvailabilityDate,
597
+ handleVisibleRangeChange,
598
+ reloadAvailabilitiesAfterReserveConflict,
599
+ refreshSelectedDateDetailsForCheckout,
600
+ fetchedRangesRef,
601
+ };
602
+ }