@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,692 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { Dispatch, SetStateAction } from 'react';
3
+ import { addWeeks, isAfter, isBefore, parseISO } from 'date-fns';
4
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
5
+ import {
6
+ getAvailabilities,
7
+ type Availability,
8
+ type GetAvailabilitiesOptions,
9
+ type PrecomputedPricesByCategory,
10
+ type PricingConfig,
11
+ type ProductOption,
12
+ } from '../../lib/booking-api';
13
+ import {
14
+ EARLIEST_AVAILABILITY_DATE,
15
+ INITIAL_FETCH_WEEKS,
16
+ } from '../../lib/booking-constants';
17
+ import { getSundayOfWeek } from '../../lib/booking/sunday-week';
18
+ import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
19
+ import {
20
+ buildAvailabilitiesCacheKey,
21
+ useAvailabilitiesCache,
22
+ } from '../../contexts/AvailabilitiesCacheContext';
23
+ import {
24
+ deriveDefaultQuantitiesFromAvailabilities,
25
+ getAvailabilityCacheKey,
26
+ parseAvailabilityDateTime,
27
+ } from './change-booking-flow-helpers';
28
+
29
+ type DateRange = { start: Date; end: Date };
30
+
31
+ type UseChangeBookingAvailabilitiesParams = {
32
+ productId: string;
33
+ productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
34
+ activeOptions: ProductOption[];
35
+ activeOptionIdsKey: string;
36
+ initialDateTime?: string | null;
37
+ companyTimezone: string;
38
+ isPartialLaunch: boolean;
39
+ appliedPromoCode: string | null;
40
+ pricingProfileIdForAvailabilities: string | null;
41
+ cancellationPolicyProfileIdForAvailabilities: string | null;
42
+ includeBookingDayResponseMode?: boolean;
43
+ filterCalendarAvailabilities?: (availabilities: Availability[]) => Availability[];
44
+ resetKey?: string | null;
45
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
46
+ setError: Dispatch<SetStateAction<string>>;
47
+ };
48
+
49
+ type UseChangeBookingAvailabilitiesResult = {
50
+ availabilities: Availability[];
51
+ calendarAvailabilities: Availability[];
52
+ loadingAvailabilities: boolean;
53
+ isFetchingMoreAvailabilities: boolean;
54
+ pricingConfig: PricingConfig | null;
55
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null;
56
+ selectedDate: string;
57
+ setSelectedDate: Dispatch<SetStateAction<string>>;
58
+ availabilitiesByDate: Record<string, Availability[]>;
59
+ dates: string[];
60
+ earliestAvailabilityDate: Date;
61
+ handleVisibleRangeChange: (start: Date, end: Date) => void;
62
+ reloadAvailabilitiesAfterReserveConflict: () => Promise<Availability[]>;
63
+ clearFetchedAvailabilityRanges: () => void;
64
+ resetAvailabilityState: () => void;
65
+ };
66
+
67
+ function needsFetch(fetchedRanges: DateRange[], start: Date, end: Date): boolean {
68
+ if (fetchedRanges.length === 0) return true;
69
+ return !fetchedRanges.some((range) => {
70
+ const rangeStart = range.start.getTime();
71
+ const rangeEnd = range.end.getTime();
72
+ const reqStart = start.getTime();
73
+ const reqEnd = end.getTime();
74
+ return rangeStart <= reqStart && rangeEnd >= reqEnd;
75
+ });
76
+ }
77
+
78
+ function mergeFetchedRanges(ranges: DateRange[]): DateRange[] {
79
+ const sorted = [...ranges].sort((a, b) => a.start.getTime() - b.start.getTime());
80
+ const merged: DateRange[] = [];
81
+ for (const range of sorted) {
82
+ const prev = merged[merged.length - 1];
83
+ if (!prev || prev.end < range.start) {
84
+ merged.push({ start: range.start, end: range.end });
85
+ } else {
86
+ prev.end = range.end > prev.end ? range.end : prev.end;
87
+ }
88
+ }
89
+ return merged;
90
+ }
91
+
92
+ function getSeasonBounds(companyTimezone: string): DateRange {
93
+ return {
94
+ start: fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone),
95
+ end: fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone),
96
+ };
97
+ }
98
+
99
+ function getStartOfCurrentDay(companyTimezone: string): Date {
100
+ const todayStr = formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd');
101
+ return fromZonedTime(parseISO(`${todayStr}T00:00:00.000`), companyTimezone);
102
+ }
103
+
104
+ function clampVisibleRange(
105
+ visibleRange: DateRange,
106
+ selectedDateKey: string,
107
+ companyTimezone: string,
108
+ ): DateRange {
109
+ const season = getSeasonBounds(companyTimezone);
110
+ const startOfCurrentDay = getStartOfCurrentDay(companyTimezone);
111
+ const minimumStart = isAfter(startOfCurrentDay, season.start) ? startOfCurrentDay : season.start;
112
+ const start = isBefore(visibleRange.start, minimumStart) ? minimumStart : visibleRange.start;
113
+ let end = isAfter(visibleRange.end, season.end) ? season.end : visibleRange.end;
114
+ if (isBefore(end, start)) {
115
+ end = start;
116
+ }
117
+
118
+ if (selectedDateKey) {
119
+ try {
120
+ const selectedDateObj = parseISO(selectedDateKey);
121
+ if (isAfter(selectedDateObj, end)) {
122
+ end = selectedDateObj;
123
+ }
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ }
128
+
129
+ return { start, end };
130
+ }
131
+
132
+ function formatAvailabilityRequestRange(
133
+ range: DateRange,
134
+ isPrivateShuttle: boolean,
135
+ companyTimezone: string,
136
+ ): { startDateStr: string; endDateStr: string } {
137
+ if (isPrivateShuttle) {
138
+ return {
139
+ startDateStr: formatInTimeZone(range.start, companyTimezone, 'yyyy-MM-dd'),
140
+ endDateStr: formatInTimeZone(range.end, companyTimezone, 'yyyy-MM-dd'),
141
+ };
142
+ }
143
+
144
+ const startDateInTz = formatInTimeZone(range.start, companyTimezone, 'yyyy-MM-dd');
145
+ const endDateInTz = formatInTimeZone(range.end, companyTimezone, 'yyyy-MM-dd');
146
+ const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
147
+ const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
148
+ return {
149
+ startDateStr: formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
150
+ endDateStr: formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
151
+ };
152
+ }
153
+
154
+ function mergePrecomputedPricesByOption(
155
+ prev: Record<string, PrecomputedPricesByCategory> | null,
156
+ results: Array<{ optionId: string; precomputedPrices?: PrecomputedPricesByCategory }>,
157
+ ): Record<string, PrecomputedPricesByCategory> | null {
158
+ const next = { ...(prev || {}) };
159
+ results.forEach((result) => {
160
+ if (result.precomputedPrices && Object.keys(result.precomputedPrices).length > 0) {
161
+ next[result.optionId] = result.precomputedPrices;
162
+ }
163
+ });
164
+ return Object.keys(next).length > 0 ? next : prev;
165
+ }
166
+
167
+ export function useChangeBookingAvailabilities({
168
+ productId,
169
+ productType,
170
+ activeOptions,
171
+ activeOptionIdsKey,
172
+ initialDateTime,
173
+ companyTimezone,
174
+ isPartialLaunch,
175
+ appliedPromoCode,
176
+ pricingProfileIdForAvailabilities,
177
+ cancellationPolicyProfileIdForAvailabilities,
178
+ includeBookingDayResponseMode = false,
179
+ filterCalendarAvailabilities,
180
+ resetKey,
181
+ setQuantities,
182
+ setError,
183
+ }: UseChangeBookingAvailabilitiesParams): UseChangeBookingAvailabilitiesResult {
184
+ const availabilitiesCache = useAvailabilitiesCache();
185
+ const [availabilities, setAvailabilities] = useState<Availability[]>([]);
186
+ const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
187
+ const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
188
+ const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
189
+ const [precomputedPricesByOption, setPrecomputedPricesByOption] =
190
+ useState<Record<string, PrecomputedPricesByCategory> | null>(null);
191
+ const [visibleRange, setVisibleRange] = useState<DateRange | null>(null);
192
+ const [selectedDate, setSelectedDate] = useState<string>(() => {
193
+ if (!initialDateTime?.trim()) return '';
194
+ try {
195
+ const target = parseAvailabilityDateTime(initialDateTime.trim());
196
+ return formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
197
+ } catch {
198
+ return '';
199
+ }
200
+ });
201
+
202
+ const activeOptionsRef = useRef(activeOptions);
203
+ const fetchingRef = useRef(false);
204
+ const hasLoadedAvailabilitiesRef = useRef(false);
205
+ const inFlightRangeRef = useRef<DateRange | null>(null);
206
+ const fetchedRangesRef = useRef<DateRange[]>([]);
207
+ const pendingRangeRef = useRef<DateRange | null>(null);
208
+ const lastVisibleRangeRef = useRef<DateRange | null>(null);
209
+ const earliestAvailabilityDateRef = useRef<Date | null>(null);
210
+ const previousResetKeyRef = useRef(resetKey);
211
+
212
+ useEffect(() => {
213
+ activeOptionsRef.current = activeOptions;
214
+ }, [activeOptions]);
215
+
216
+ const applyPricingConfig = useCallback((next?: PricingConfig | null) => {
217
+ setPricingConfig((prev) => (shouldReplacePricingConfig(prev, next) ? next ?? null : prev));
218
+ }, []);
219
+
220
+ const clearFetchedAvailabilityRanges = useCallback(() => {
221
+ fetchedRangesRef.current = [];
222
+ }, []);
223
+
224
+ const resetAvailabilityState = useCallback(() => {
225
+ setAvailabilities([]);
226
+ setPrecomputedPricesByOption(null);
227
+ setPricingConfig(null);
228
+ setIsFetchingMoreAvailabilities(false);
229
+ fetchingRef.current = false;
230
+ hasLoadedAvailabilitiesRef.current = false;
231
+ inFlightRangeRef.current = null;
232
+ fetchedRangesRef.current = [];
233
+ pendingRangeRef.current = null;
234
+ earliestAvailabilityDateRef.current = null;
235
+ }, []);
236
+
237
+ useEffect(() => {
238
+ if (previousResetKeyRef.current === resetKey) return;
239
+ previousResetKeyRef.current = resetKey;
240
+ resetAvailabilityState();
241
+ }, [resetAvailabilityState, resetKey]);
242
+
243
+ const selectedDateRangeExtensionKey = useMemo(() => {
244
+ if (!visibleRange || !selectedDate) return '';
245
+ try {
246
+ return isAfter(parseISO(selectedDate), visibleRange.end) ? selectedDate : '';
247
+ } catch {
248
+ return '';
249
+ }
250
+ }, [selectedDate, visibleRange]);
251
+
252
+ const isPrivateShuttle = productType === 'PRIVATE_SHUTTLE';
253
+
254
+ const buildCacheKey = useCallback(() => {
255
+ return availabilitiesCache
256
+ ? buildAvailabilitiesCacheKey(
257
+ productId,
258
+ activeOptionIdsKey,
259
+ appliedPromoCode,
260
+ pricingProfileIdForAvailabilities,
261
+ cancellationPolicyProfileIdForAvailabilities,
262
+ )
263
+ : null;
264
+ }, [
265
+ activeOptionIdsKey,
266
+ appliedPromoCode,
267
+ availabilitiesCache,
268
+ cancellationPolicyProfileIdForAvailabilities,
269
+ pricingProfileIdForAvailabilities,
270
+ productId,
271
+ ]);
272
+
273
+ const fetchAvailabilityResults = useCallback(
274
+ async (range: DateRange) => {
275
+ const { startDateStr, endDateStr } = formatAvailabilityRequestRange(
276
+ range,
277
+ isPrivateShuttle,
278
+ companyTimezone,
279
+ );
280
+ const options = activeOptionsRef.current;
281
+ const availabilityPromises = options.map(async (option) => {
282
+ const requestOptions: GetAvailabilitiesOptions = {
283
+ ...(includeBookingDayResponseMode && !isPrivateShuttle
284
+ ? { responseMode: 'booking-day' as const }
285
+ : {}),
286
+ promoCode: appliedPromoCode || undefined,
287
+ ...(pricingProfileIdForAvailabilities
288
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
289
+ : {}),
290
+ ...(cancellationPolicyProfileIdForAvailabilities
291
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
292
+ : {}),
293
+ };
294
+ const result = await getAvailabilities(
295
+ option.optionId,
296
+ startDateStr,
297
+ endDateStr,
298
+ requestOptions,
299
+ );
300
+ applyPricingConfig(result.pricingConfig);
301
+ return {
302
+ optionId: option.optionId,
303
+ availabilities: result.availabilities.map((avail) => ({
304
+ ...avail,
305
+ productOptionId: option.optionId,
306
+ })),
307
+ precomputedPrices: result.precomputedPrices,
308
+ pricingConfig: result.pricingConfig,
309
+ };
310
+ });
311
+
312
+ return Promise.all(availabilityPromises);
313
+ },
314
+ [
315
+ appliedPromoCode,
316
+ applyPricingConfig,
317
+ cancellationPolicyProfileIdForAvailabilities,
318
+ companyTimezone,
319
+ includeBookingDayResponseMode,
320
+ isPrivateShuttle,
321
+ pricingProfileIdForAvailabilities,
322
+ ],
323
+ );
324
+
325
+ const mergeAvailabilitiesIntoState = useCallback((incoming: Availability[]): Availability[] => {
326
+ let mergedOut: Availability[] = [];
327
+ setAvailabilities((prev) => {
328
+ const existingMap = new Map(prev.map((avail) => [getAvailabilityCacheKey(avail), avail]));
329
+ incoming.forEach((avail) => {
330
+ existingMap.set(getAvailabilityCacheKey(avail), avail);
331
+ });
332
+ mergedOut = Array.from(existingMap.values());
333
+ return mergedOut;
334
+ });
335
+ return mergedOut;
336
+ }, []);
337
+
338
+ const mergeResultsIntoCache = useCallback(
339
+ (
340
+ cacheKey: string | null,
341
+ fetchedRanges: DateRange[],
342
+ fetchedAvailabilities: Availability[],
343
+ results: Array<{
344
+ optionId: string;
345
+ precomputedPrices?: PrecomputedPricesByCategory;
346
+ pricingConfig?: PricingConfig;
347
+ }>,
348
+ ) => {
349
+ if (!cacheKey || !availabilitiesCache) return;
350
+ const existingCache = availabilitiesCache.get(cacheKey);
351
+ const existingAvailabilities = existingCache?.availabilities ?? [];
352
+ const mergedAvailabilitiesMap = new Map(
353
+ existingAvailabilities.map((avail) => [getAvailabilityCacheKey(avail), avail]),
354
+ );
355
+ fetchedAvailabilities.forEach((avail) => {
356
+ mergedAvailabilitiesMap.set(getAvailabilityCacheKey(avail), avail);
357
+ });
358
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
359
+ results.forEach((result) => {
360
+ if (result.precomputedPrices && Object.keys(result.precomputedPrices).length > 0) {
361
+ mergedPrecomputed[result.optionId] = result.precomputedPrices;
362
+ }
363
+ });
364
+ const firstPricingConfig =
365
+ (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ??
366
+ existingCache?.pricingConfig ??
367
+ null;
368
+ availabilitiesCache.merge(cacheKey, {
369
+ fetchedRanges,
370
+ availabilities: Array.from(mergedAvailabilitiesMap.values()),
371
+ pricingConfig: firstPricingConfig,
372
+ precomputedPricesByOption:
373
+ Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
374
+ });
375
+ },
376
+ [availabilitiesCache],
377
+ );
378
+
379
+ const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
380
+ if (isPartialLaunch || !visibleRange || activeOptionsRef.current.length === 0) {
381
+ return [];
382
+ }
383
+
384
+ const clampedRange = clampVisibleRange(visibleRange, selectedDate, companyTimezone);
385
+ const results = await fetchAvailabilityResults(clampedRange);
386
+ const allFetchedAvailabilities = results.flatMap((result) => result.availabilities);
387
+
388
+ setPrecomputedPricesByOption((prev) => mergePrecomputedPricesByOption(prev, results));
389
+ const mergedOut = mergeAvailabilitiesIntoState(allFetchedAvailabilities);
390
+
391
+ fetchedRangesRef.current = mergeFetchedRanges([
392
+ ...fetchedRangesRef.current,
393
+ { start: new Date(clampedRange.start), end: new Date(clampedRange.end) },
394
+ ]);
395
+
396
+ mergeResultsIntoCache(
397
+ buildCacheKey(),
398
+ fetchedRangesRef.current,
399
+ allFetchedAvailabilities,
400
+ results,
401
+ );
402
+
403
+ return mergedOut;
404
+ }, [
405
+ buildCacheKey,
406
+ companyTimezone,
407
+ fetchAvailabilityResults,
408
+ isPartialLaunch,
409
+ mergeAvailabilitiesIntoState,
410
+ mergeResultsIntoCache,
411
+ selectedDate,
412
+ visibleRange,
413
+ ]);
414
+
415
+ useEffect(() => {
416
+ if (visibleRange) return;
417
+
418
+ const season = getSeasonBounds(companyTimezone);
419
+ const startOfCurrentDay = getStartOfCurrentDay(companyTimezone);
420
+ const minimumStart = isAfter(startOfCurrentDay, season.start) ? startOfCurrentDay : season.start;
421
+ if (initialDateTime?.trim()) {
422
+ try {
423
+ const target = parseAvailabilityDateTime(initialDateTime.trim());
424
+ const dateStr = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
425
+ const weekStartStr = getSundayOfWeek(dateStr, companyTimezone);
426
+ let rangeStart = fromZonedTime(parseISO(`${weekStartStr}T12:00:00`), companyTimezone);
427
+ if (isBefore(rangeStart, minimumStart)) {
428
+ rangeStart = minimumStart;
429
+ }
430
+ let rangeEnd = addWeeks(rangeStart, INITIAL_FETCH_WEEKS);
431
+ if (isAfter(rangeEnd, season.end)) {
432
+ rangeEnd = season.end;
433
+ }
434
+ if (!isBefore(rangeEnd, rangeStart)) {
435
+ setVisibleRange({ start: rangeStart, end: rangeEnd });
436
+ return;
437
+ }
438
+ } catch {
439
+ /* fall through to default */
440
+ }
441
+ }
442
+
443
+ let initialEnd = addWeeks(minimumStart, INITIAL_FETCH_WEEKS);
444
+ if (isAfter(initialEnd, season.end)) {
445
+ initialEnd = season.end;
446
+ }
447
+ setVisibleRange({ start: minimumStart, end: initialEnd });
448
+ }, [companyTimezone, initialDateTime, visibleRange]);
449
+
450
+ useEffect(() => {
451
+ if (isPartialLaunch) {
452
+ setLoadingAvailabilities(false);
453
+ return;
454
+ }
455
+ if (activeOptionsRef.current.length === 0) {
456
+ setError('No active product options available');
457
+ setLoadingAvailabilities(false);
458
+ return;
459
+ }
460
+ if (!visibleRange) return;
461
+
462
+ async function fetchAvailabilitiesForVisibleRange() {
463
+ if (!visibleRange) return;
464
+ if (fetchingRef.current) {
465
+ const inFlight = inFlightRangeRef.current;
466
+ if (
467
+ inFlight &&
468
+ inFlight.start.getTime() === visibleRange.start.getTime() &&
469
+ inFlight.end.getTime() === visibleRange.end.getTime()
470
+ ) {
471
+ return;
472
+ }
473
+ pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
474
+ return;
475
+ }
476
+
477
+ const clampedRange = clampVisibleRange(
478
+ visibleRange,
479
+ selectedDateRangeExtensionKey,
480
+ companyTimezone,
481
+ );
482
+ const cacheKey = buildCacheKey();
483
+ const cached = cacheKey ? availabilitiesCache?.get(cacheKey) : undefined;
484
+
485
+ if (cached && cached.availabilities.length > 0) {
486
+ const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
487
+ if (cachedInitialQuantities) {
488
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : cachedInitialQuantities));
489
+ }
490
+ const cacheCoversRange = cached.fetchedRanges.some(
491
+ (range) =>
492
+ range.start.getTime() <= clampedRange.start.getTime() &&
493
+ range.end.getTime() >= clampedRange.end.getTime(),
494
+ );
495
+ const isStale = availabilitiesCache?.isStale(cached) ?? false;
496
+ setAvailabilities(cached.availabilities);
497
+ if (cached.availabilities.length > 0) {
498
+ hasLoadedAvailabilitiesRef.current = true;
499
+ }
500
+ if (cached.pricingConfig) {
501
+ setPricingConfig(cached.pricingConfig);
502
+ }
503
+ if (
504
+ cached.precomputedPricesByOption &&
505
+ Object.keys(cached.precomputedPricesByOption).length > 0
506
+ ) {
507
+ setPrecomputedPricesByOption(cached.precomputedPricesByOption);
508
+ }
509
+ setLoadingAvailabilities(false);
510
+ if (cacheCoversRange) {
511
+ setIsFetchingMoreAvailabilities(false);
512
+ if (!isStale) {
513
+ fetchedRangesRef.current = [...cached.fetchedRanges];
514
+ fetchingRef.current = false;
515
+ return;
516
+ }
517
+ }
518
+ fetchedRangesRef.current = [...cached.fetchedRanges];
519
+ }
520
+
521
+ if (!needsFetch(fetchedRangesRef.current, clampedRange.start, clampedRange.end)) {
522
+ setLoadingAvailabilities(false);
523
+ setIsFetchingMoreAvailabilities(false);
524
+ fetchingRef.current = false;
525
+ return;
526
+ }
527
+
528
+ const hasPartialCache = cached && cached.availabilities.length > 0;
529
+ const shouldUsePrimaryLoader = !hasPartialCache && !hasLoadedAvailabilitiesRef.current;
530
+ fetchingRef.current = true;
531
+ inFlightRangeRef.current = {
532
+ start: new Date(clampedRange.start),
533
+ end: new Date(clampedRange.end),
534
+ };
535
+ if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
536
+ else setIsFetchingMoreAvailabilities(true);
537
+
538
+ try {
539
+ const results = await fetchAvailabilityResults(clampedRange);
540
+ const allFetchedAvailabilities = results.flatMap((result) => result.availabilities);
541
+ if (allFetchedAvailabilities.length > 0) {
542
+ hasLoadedAvailabilitiesRef.current = true;
543
+ }
544
+
545
+ setPrecomputedPricesByOption((prev) => mergePrecomputedPricesByOption(prev, results));
546
+ mergeAvailabilitiesIntoState(allFetchedAvailabilities);
547
+
548
+ fetchedRangesRef.current = mergeFetchedRanges([
549
+ ...fetchedRangesRef.current,
550
+ { start: new Date(clampedRange.start), end: new Date(clampedRange.end) },
551
+ ]);
552
+
553
+ mergeResultsIntoCache(
554
+ cacheKey,
555
+ fetchedRangesRef.current,
556
+ allFetchedAvailabilities,
557
+ results,
558
+ );
559
+
560
+ const fetchedInitialQuantities =
561
+ deriveDefaultQuantitiesFromAvailabilities(allFetchedAvailabilities);
562
+ if (fetchedInitialQuantities) {
563
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
564
+ }
565
+ } catch (err) {
566
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
567
+ console.error('Error fetching availabilities:', err);
568
+ } finally {
569
+ setLoadingAvailabilities(false);
570
+ setIsFetchingMoreAvailabilities(false);
571
+ fetchingRef.current = false;
572
+ inFlightRangeRef.current = null;
573
+ const pending = pendingRangeRef.current;
574
+ if (pending) {
575
+ pendingRangeRef.current = null;
576
+ setVisibleRange({ start: pending.start, end: pending.end });
577
+ }
578
+ }
579
+ }
580
+
581
+ fetchAvailabilitiesForVisibleRange();
582
+ }, [
583
+ activeOptionIdsKey,
584
+ appliedPromoCode,
585
+ availabilitiesCache,
586
+ buildCacheKey,
587
+ cancellationPolicyProfileIdForAvailabilities,
588
+ companyTimezone,
589
+ fetchAvailabilityResults,
590
+ isPartialLaunch,
591
+ mergeAvailabilitiesIntoState,
592
+ mergeResultsIntoCache,
593
+ pricingProfileIdForAvailabilities,
594
+ selectedDateRangeExtensionKey,
595
+ setError,
596
+ setQuantities,
597
+ visibleRange,
598
+ ]);
599
+
600
+ useEffect(() => {
601
+ fetchedRangesRef.current = [];
602
+ }, [
603
+ appliedPromoCode,
604
+ pricingProfileIdForAvailabilities,
605
+ cancellationPolicyProfileIdForAvailabilities,
606
+ ]);
607
+
608
+ const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
609
+ const lastRange = lastVisibleRangeRef.current;
610
+ const rangeChanged =
611
+ !lastRange ||
612
+ Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
613
+ Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
614
+
615
+ if (rangeChanged) {
616
+ lastVisibleRangeRef.current = { start, end };
617
+ setVisibleRange({ start, end });
618
+ }
619
+ }, []);
620
+
621
+ const calendarAvailabilities = useMemo(
622
+ () => {
623
+ const todayStr = formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd');
624
+ const currentOrFutureAvailabilities = availabilities.filter((avail) => {
625
+ const dateTime = parseAvailabilityDateTime(avail.dateTime);
626
+ return formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd') >= todayStr;
627
+ });
628
+ return filterCalendarAvailabilities
629
+ ? filterCalendarAvailabilities(currentOrFutureAvailabilities)
630
+ : currentOrFutureAvailabilities;
631
+ },
632
+ [availabilities, companyTimezone, filterCalendarAvailabilities],
633
+ );
634
+
635
+ const availabilitiesByDate = useMemo(() => {
636
+ const grouped = calendarAvailabilities.reduce((acc, avail) => {
637
+ const dateTime = parseAvailabilityDateTime(avail.dateTime);
638
+ const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
639
+ if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
640
+ acc[dateInCompanyTz].push(avail);
641
+ return acc;
642
+ }, {} as Record<string, Availability[]>);
643
+
644
+ const sorted: Record<string, Availability[]> = {};
645
+ Object.keys(grouped).forEach((date) => {
646
+ sorted[date] = [...grouped[date]].sort((a, b) => {
647
+ const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
648
+ const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
649
+ return timeA - timeB;
650
+ });
651
+ });
652
+
653
+ return sorted;
654
+ }, [calendarAvailabilities, companyTimezone]);
655
+
656
+ const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
657
+
658
+ const earliestAvailabilityDate = useMemo(() => {
659
+ if (dates.length === 0) {
660
+ return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
661
+ }
662
+ const firstDate = dates[0];
663
+ const firstAvail = availabilitiesByDate[firstDate]?.[0];
664
+ const newEarliest = firstAvail
665
+ ? parseISO(firstAvail.dateTime)
666
+ : fromZonedTime(parseISO(`${firstDate}T12:00:00`), companyTimezone);
667
+
668
+ if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
669
+ earliestAvailabilityDateRef.current = newEarliest;
670
+ }
671
+
672
+ return earliestAvailabilityDateRef.current ?? newEarliest;
673
+ }, [availabilitiesByDate, companyTimezone, dates]);
674
+
675
+ return {
676
+ availabilities,
677
+ calendarAvailabilities,
678
+ loadingAvailabilities,
679
+ isFetchingMoreAvailabilities,
680
+ pricingConfig,
681
+ precomputedPricesByOption,
682
+ selectedDate,
683
+ setSelectedDate,
684
+ availabilitiesByDate,
685
+ dates,
686
+ earliestAvailabilityDate,
687
+ handleVisibleRangeChange,
688
+ reloadAvailabilitiesAfterReserveConflict,
689
+ clearFetchedAvailabilityRanges,
690
+ resetAvailabilityState,
691
+ };
692
+ }