@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,362 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { parseISO } from 'date-fns';
3
+ import { formatInTimeZone } from 'date-fns-tz';
4
+ import type { Dispatch, SetStateAction } from 'react';
5
+ import type { Availability, ProductOption, ReturnOption } from '../../lib/booking-api';
6
+ import {
7
+ getAvailabilityOptionId,
8
+ getMinutesFromMidnightInTimezone,
9
+ parseAvailabilityDateTime,
10
+ pickOutboundMatchingPreviousSelection,
11
+ pickReturnMatchingPreviousSelection,
12
+ } from './change-booking-flow-helpers';
13
+
14
+ type ChangeSelectionInitialValues = {
15
+ dateTime?: string | null;
16
+ returnAvailabilityId?: string | null;
17
+ returnDateTime?: string | null;
18
+ };
19
+
20
+ type UseChangeBookingSelectionParams = {
21
+ availabilities: Availability[];
22
+ selectedDate: string;
23
+ timesForSelectedDate: Availability[];
24
+ timesForSelectedDateSelectionKey: string;
25
+ activeOptions: ProductOption[];
26
+ activeOptionIdsKey: string;
27
+ optionsMap: Map<string, ProductOption>;
28
+ selectedAvailability: Availability | null;
29
+ selectedReturnOption: ReturnOption | null;
30
+ initialValues?: ChangeSelectionInitialValues | null;
31
+ companyTimezone: string;
32
+ isAdmin: boolean;
33
+ getSelectableTimesForDate?: (date: string) => Availability[];
34
+ isAvailabilitySelectable?: (availability: Availability) => boolean;
35
+ validateSelectedReturnOption?: boolean;
36
+ setSelectedAvailability: Dispatch<SetStateAction<Availability | null>>;
37
+ setSelectedReturnOption: Dispatch<SetStateAction<ReturnOption | null>>;
38
+ setImplicitReturnBaselineId: Dispatch<SetStateAction<string | null>>;
39
+ setError: Dispatch<SetStateAction<string>>;
40
+ };
41
+
42
+ export function useChangeBookingTimesForSelectedDate(
43
+ selectedDate: string,
44
+ availabilitiesByDate: Record<string, Availability[]>,
45
+ ): {
46
+ timesForSelectedDate: Availability[];
47
+ timesForSelectedDateSelectionKey: string;
48
+ } {
49
+ const timesForSelectedDate = useMemo(() => {
50
+ return selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
51
+ }, [selectedDate, availabilitiesByDate]);
52
+
53
+ const timesForSelectedDateSelectionKey = useMemo(
54
+ () =>
55
+ timesForSelectedDate
56
+ .map(
57
+ (avail) =>
58
+ `${avail.dateTime}|${getAvailabilityOptionId(avail)}|${avail.vacancies ?? 0}`,
59
+ )
60
+ .join('||'),
61
+ [timesForSelectedDate],
62
+ );
63
+
64
+ return { timesForSelectedDate, timesForSelectedDateSelectionKey };
65
+ }
66
+
67
+ export function useChangeBookingSelection({
68
+ availabilities,
69
+ selectedDate,
70
+ timesForSelectedDate,
71
+ timesForSelectedDateSelectionKey,
72
+ activeOptions,
73
+ activeOptionIdsKey,
74
+ optionsMap,
75
+ selectedAvailability,
76
+ selectedReturnOption,
77
+ initialValues,
78
+ companyTimezone,
79
+ isAdmin,
80
+ getSelectableTimesForDate,
81
+ isAvailabilitySelectable,
82
+ validateSelectedReturnOption = false,
83
+ setSelectedAvailability,
84
+ setSelectedReturnOption,
85
+ setImplicitReturnBaselineId,
86
+ setError,
87
+ }: UseChangeBookingSelectionParams): {
88
+ handleDateSelect: (date: string) => void;
89
+ handleTimeSelect: (availability: Availability) => void;
90
+ clearChangeBookingSelectionAnchors: () => void;
91
+ } {
92
+ const changeFlowOutboundAnchorRef = useRef<{
93
+ productOptionId: string | null;
94
+ minutesFromMidnight: number;
95
+ } | null>(null);
96
+ const changeFlowReturnAnchorRef = useRef<{
97
+ returnLocation: string;
98
+ minutesFromMidnight: number;
99
+ } | null>(null);
100
+
101
+ useEffect(() => {
102
+ if (!selectedDate || timesForSelectedDate.length === 0 || selectedAvailability) return;
103
+ const selectableTimesForDate = getSelectableTimesForDate?.(selectedDate) ?? timesForSelectedDate;
104
+ if (selectableTimesForDate.length === 0) return;
105
+
106
+ // On the booking's calendar day, the initial-resolution effect owns the booked pickup match.
107
+ if (initialValues?.dateTime?.trim()) {
108
+ try {
109
+ const bookingDay = formatInTimeZone(
110
+ parseAvailabilityDateTime(initialValues.dateTime.trim()),
111
+ companyTimezone,
112
+ 'yyyy-MM-dd',
113
+ );
114
+ if (selectedDate === bookingDay) return;
115
+ } catch {
116
+ /* fall through */
117
+ }
118
+ }
119
+
120
+ const anchor = changeFlowOutboundAnchorRef.current;
121
+ if (anchor) {
122
+ const matched = pickOutboundMatchingPreviousSelection(
123
+ selectableTimesForDate,
124
+ anchor,
125
+ companyTimezone,
126
+ optionsMap,
127
+ isAdmin,
128
+ );
129
+ changeFlowOutboundAnchorRef.current = null;
130
+ if (matched) {
131
+ setSelectedAvailability(matched);
132
+ setError('');
133
+ return;
134
+ }
135
+ }
136
+
137
+ const mostPopularOption = activeOptions.find((opt) => opt.mostPopular);
138
+ const candidate = mostPopularOption
139
+ ? selectableTimesForDate.find(
140
+ (avail) =>
141
+ getAvailabilityOptionId(avail) === mostPopularOption.optionId &&
142
+ avail.vacancies > 0,
143
+ )
144
+ : null;
145
+ const fallback = selectableTimesForDate.find((avail) => avail.vacancies > 0);
146
+ const toSelect = candidate ?? fallback;
147
+ if (toSelect) {
148
+ setSelectedAvailability(toSelect);
149
+ setError('');
150
+ }
151
+ }, [
152
+ selectedDate,
153
+ timesForSelectedDate,
154
+ timesForSelectedDateSelectionKey,
155
+ activeOptions,
156
+ activeOptionIdsKey,
157
+ selectedAvailability,
158
+ initialValues?.dateTime,
159
+ companyTimezone,
160
+ getSelectableTimesForDate,
161
+ optionsMap,
162
+ isAdmin,
163
+ setSelectedAvailability,
164
+ setError,
165
+ ]);
166
+
167
+ useEffect(() => {
168
+ if (!selectedAvailability || availabilities.length === 0) return;
169
+ const updatedAvailability = availabilities.find(
170
+ (avail) =>
171
+ avail.dateTime === selectedAvailability.dateTime &&
172
+ getAvailabilityOptionId(avail) === getAvailabilityOptionId(selectedAvailability),
173
+ );
174
+ if (!updatedAvailability) return;
175
+
176
+ setSelectedAvailability(updatedAvailability);
177
+
178
+ if (selectedReturnOption && updatedAvailability.returnOptions) {
179
+ const updatedReturnOption = updatedAvailability.returnOptions.find(
180
+ (opt) => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
181
+ );
182
+ if (updatedReturnOption) {
183
+ setSelectedReturnOption(updatedReturnOption);
184
+ }
185
+ }
186
+ }, [
187
+ availabilities,
188
+ selectedAvailability,
189
+ selectedReturnOption,
190
+ setSelectedAvailability,
191
+ setSelectedReturnOption,
192
+ ]);
193
+
194
+ const selectedAvailabilityOptionId = selectedAvailability
195
+ ? getAvailabilityOptionId(selectedAvailability)
196
+ : '';
197
+
198
+ useEffect(() => {
199
+ const hasApiReturnHint =
200
+ Boolean(initialValues?.returnAvailabilityId?.trim()) ||
201
+ Boolean(initialValues?.returnDateTime?.trim());
202
+ if (hasApiReturnHint) {
203
+ setImplicitReturnBaselineId(null);
204
+ return;
205
+ }
206
+ setImplicitReturnBaselineId(null);
207
+ }, [
208
+ selectedAvailability?.dateTime,
209
+ selectedAvailabilityOptionId,
210
+ initialValues?.returnAvailabilityId,
211
+ initialValues?.returnDateTime,
212
+ setImplicitReturnBaselineId,
213
+ ]);
214
+
215
+ useEffect(() => {
216
+ if (!selectedAvailability?.returnOptions?.length || selectedReturnOption) return;
217
+ const sorted = [...selectedAvailability.returnOptions].sort(
218
+ (a, b) => parseISO(a.dateTime).getTime() - parseISO(b.dateTime).getTime(),
219
+ );
220
+ const initialReturnIdForSelect = initialValues?.returnAvailabilityId?.trim();
221
+ const initialReturnDtForSelect = initialValues?.returnDateTime?.trim();
222
+
223
+ const returnAnchor = changeFlowReturnAnchorRef.current;
224
+ if (returnAnchor) {
225
+ const fromAnchor = pickReturnMatchingPreviousSelection(
226
+ sorted,
227
+ returnAnchor,
228
+ companyTimezone,
229
+ isAdmin,
230
+ );
231
+ changeFlowReturnAnchorRef.current = null;
232
+ if (fromAnchor) {
233
+ setSelectedReturnOption(fromAnchor);
234
+ const hasApiReturnHintFromAnchor =
235
+ Boolean(initialReturnIdForSelect) || Boolean(initialReturnDtForSelect);
236
+ if (!hasApiReturnHintFromAnchor) {
237
+ setImplicitReturnBaselineId((prev) => prev ?? fromAnchor.returnAvailabilityId);
238
+ }
239
+ return;
240
+ }
241
+ }
242
+
243
+ const preferBooked =
244
+ initialReturnIdForSelect
245
+ ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
246
+ : undefined;
247
+ const preferByDateTime =
248
+ initialReturnDtForSelect && !preferBooked
249
+ ? sorted.find((opt) => {
250
+ try {
251
+ return (
252
+ parseAvailabilityDateTime(opt.dateTime).getTime() ===
253
+ parseAvailabilityDateTime(initialReturnDtForSelect).getTime()
254
+ );
255
+ } catch {
256
+ return false;
257
+ }
258
+ })
259
+ : undefined;
260
+ const firstAvailable = sorted.find((opt) => opt.vacancies > 0);
261
+ const toSelect = preferBooked ?? preferByDateTime ?? firstAvailable;
262
+ if (toSelect) {
263
+ setSelectedReturnOption(toSelect);
264
+ const hasApiReturnHintToSelect =
265
+ Boolean(initialReturnIdForSelect) || Boolean(initialReturnDtForSelect);
266
+ if (!hasApiReturnHintToSelect) {
267
+ setImplicitReturnBaselineId((prev) => prev ?? toSelect.returnAvailabilityId);
268
+ }
269
+ }
270
+ }, [
271
+ selectedAvailability,
272
+ selectedReturnOption,
273
+ companyTimezone,
274
+ isAdmin,
275
+ initialValues?.returnAvailabilityId,
276
+ initialValues?.returnDateTime,
277
+ setSelectedReturnOption,
278
+ setImplicitReturnBaselineId,
279
+ ]);
280
+
281
+ useEffect(() => {
282
+ if (!validateSelectedReturnOption || !selectedReturnOption) return;
283
+ const returnOptions = selectedAvailability?.returnOptions ?? [];
284
+ if (returnOptions.length === 0) {
285
+ setSelectedReturnOption(null);
286
+ return;
287
+ }
288
+ const updatedReturnOption = returnOptions.find(
289
+ (opt) => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId,
290
+ );
291
+ if (!updatedReturnOption) {
292
+ setSelectedReturnOption(null);
293
+ }
294
+ }, [
295
+ validateSelectedReturnOption,
296
+ selectedAvailability?.returnOptions,
297
+ selectedReturnOption,
298
+ setSelectedReturnOption,
299
+ ]);
300
+
301
+ const handleDateSelect = useCallback(
302
+ (date: string) => {
303
+ if (date === selectedDate) return;
304
+ if (selectedAvailability) {
305
+ changeFlowOutboundAnchorRef.current = {
306
+ productOptionId: getAvailabilityOptionId(selectedAvailability) || null,
307
+ minutesFromMidnight: getMinutesFromMidnightInTimezone(
308
+ selectedAvailability.dateTime,
309
+ companyTimezone,
310
+ ),
311
+ };
312
+ if (selectedReturnOption) {
313
+ changeFlowReturnAnchorRef.current = {
314
+ returnLocation: selectedReturnOption.returnLocation,
315
+ minutesFromMidnight: getMinutesFromMidnightInTimezone(
316
+ selectedReturnOption.dateTime,
317
+ companyTimezone,
318
+ ),
319
+ };
320
+ } else {
321
+ changeFlowReturnAnchorRef.current = null;
322
+ }
323
+ }
324
+ setSelectedAvailability(null);
325
+ setSelectedReturnOption(null);
326
+ },
327
+ [
328
+ selectedDate,
329
+ selectedAvailability,
330
+ selectedReturnOption,
331
+ companyTimezone,
332
+ setSelectedAvailability,
333
+ setSelectedReturnOption,
334
+ ],
335
+ );
336
+
337
+ const handleTimeSelect = useCallback(
338
+ (availability: Availability) => {
339
+ if (isAvailabilitySelectable && !isAvailabilitySelectable(availability)) return;
340
+ setSelectedAvailability(availability);
341
+ setSelectedReturnOption(null);
342
+ setError('');
343
+ },
344
+ [
345
+ isAvailabilitySelectable,
346
+ setSelectedAvailability,
347
+ setSelectedReturnOption,
348
+ setError,
349
+ ],
350
+ );
351
+
352
+ const clearChangeBookingSelectionAnchors = useCallback(() => {
353
+ changeFlowOutboundAnchorRef.current = null;
354
+ changeFlowReturnAnchorRef.current = null;
355
+ }, []);
356
+
357
+ return {
358
+ handleDateSelect,
359
+ handleTimeSelect,
360
+ clearChangeBookingSelectionAnchors,
361
+ };
362
+ }
@@ -0,0 +1,200 @@
1
+ import { useMemo } from 'react';
2
+ import type { Availability, ReturnOption } from '../../lib/booking-api';
3
+ import { normalizeProductOptionIdForChangeFlow } from '../../lib/booking/product-option-id';
4
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
5
+ import {
6
+ getAvailabilityOptionId,
7
+ parseAvailabilityDateTime,
8
+ } from './change-booking-flow-helpers';
9
+
10
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
11
+
12
+ export interface ChangeBookingSelectionDetails {
13
+ hasChangesFromInitial: boolean;
14
+ hasOperationalChangesFromInitial: boolean;
15
+ dateChanged: boolean;
16
+ ticketsChanged: boolean;
17
+ productChanged: boolean;
18
+ optionChanged: boolean;
19
+ pickupChanged: boolean;
20
+ countsChanged: boolean;
21
+ addOnsChanged: boolean;
22
+ returnChanged: boolean;
23
+ }
24
+
25
+ export interface UseChangeBookingSelectionDetailsParams {
26
+ initialValues: ChangeBookingFlowProps['initialValues'];
27
+ selectedAvailability: Availability | null;
28
+ selectedReturnOption: ReturnOption | null;
29
+ implicitReturnBaselineId: string | null;
30
+ pickupLocationId: string | null;
31
+ quantities: Record<string, number>;
32
+ addOnSelections: AddOnSelection[];
33
+ initialAddOnMinQtyByKey: Map<string, number>;
34
+ changeFlowResolvedInitialProductOptionId: string | null;
35
+ productChanged?: boolean;
36
+ }
37
+
38
+ function emptyChangeSelectionDetails(): ChangeBookingSelectionDetails {
39
+ return {
40
+ hasChangesFromInitial: false,
41
+ hasOperationalChangesFromInitial: false,
42
+ dateChanged: false,
43
+ ticketsChanged: false,
44
+ productChanged: false,
45
+ optionChanged: false,
46
+ pickupChanged: false,
47
+ countsChanged: false,
48
+ addOnsChanged: false,
49
+ returnChanged: false,
50
+ };
51
+ }
52
+
53
+ function normalizePickupId(value: string | null | undefined): string | null {
54
+ const trimmed = value?.trim();
55
+ return trimmed ? trimmed : null;
56
+ }
57
+
58
+ function normalizeCounts(items: Array<{ category: string; count: number }> | null | undefined): Map<string, number> {
59
+ const map = new Map<string, number>();
60
+ for (const item of items ?? []) {
61
+ const key = item.category?.trim();
62
+ if (!key) continue;
63
+ map.set(key, Math.max(0, Number(item.count) || 0));
64
+ }
65
+ return map;
66
+ }
67
+
68
+ export function useChangeBookingSelectionDetails({
69
+ initialValues,
70
+ selectedAvailability,
71
+ selectedReturnOption,
72
+ implicitReturnBaselineId,
73
+ pickupLocationId,
74
+ quantities,
75
+ addOnSelections,
76
+ initialAddOnMinQtyByKey,
77
+ changeFlowResolvedInitialProductOptionId,
78
+ productChanged = false,
79
+ }: UseChangeBookingSelectionDetailsParams): ChangeBookingSelectionDetails {
80
+ return useMemo(() => {
81
+ if (!initialValues || !selectedAvailability) {
82
+ return emptyChangeSelectionDetails();
83
+ }
84
+
85
+ const initialMs = initialValues.dateTime ? parseAvailabilityDateTime(initialValues.dateTime).getTime() : null;
86
+ const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
87
+ const dateChanged = initialMs != null && initialMs !== selectedMs;
88
+
89
+ const initialOpt =
90
+ changeFlowResolvedInitialProductOptionId ??
91
+ (initialValues.productOptionId?.trim() || null);
92
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(getAvailabilityOptionId(selectedAvailability));
93
+ const optionChanged = Boolean(
94
+ selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
95
+ );
96
+
97
+ const pickupChanged =
98
+ normalizePickupId(initialValues.pickupLocationId ?? null) !==
99
+ normalizePickupId(pickupLocationId ?? null);
100
+
101
+ const initialCounts = normalizeCounts(initialValues.bookingItems ?? null);
102
+ const currentCounts = new Map<string, number>();
103
+ for (const [category, count] of Object.entries(quantities)) {
104
+ const key = category?.trim();
105
+ if (!key) continue;
106
+ currentCounts.set(key, Math.max(0, Number(count) || 0));
107
+ }
108
+
109
+ const allCountKeys = new Set([...initialCounts.keys(), ...currentCounts.keys()]);
110
+ let countsChanged = false;
111
+ for (const key of allCountKeys) {
112
+ if ((initialCounts.get(key) ?? 0) !== (currentCounts.get(key) ?? 0)) {
113
+ countsChanged = true;
114
+ break;
115
+ }
116
+ }
117
+
118
+ const currentAddOnQtyByKey = new Map<string, number>();
119
+ for (const sel of addOnSelections) {
120
+ const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
121
+ currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
122
+ }
123
+
124
+ const allAddOnKeys = new Set([...initialAddOnMinQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
125
+ let addOnsChanged = false;
126
+ for (const key of allAddOnKeys) {
127
+ if ((initialAddOnMinQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
128
+ addOnsChanged = true;
129
+ break;
130
+ }
131
+ }
132
+
133
+ const hasReturnOptions = (selectedAvailability?.returnOptions?.length ?? 0) > 0;
134
+ const initialReturnId = initialValues.returnAvailabilityId?.trim() || null;
135
+ const initialReturnDt = initialValues.returnDateTime?.trim() || null;
136
+ const selectedReturnId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
137
+ const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
138
+ let returnChanged = false;
139
+
140
+ if (hasReturnOptions && selectedReturnOption) {
141
+ if (initialReturnId && selectedReturnId) {
142
+ if (initialReturnId === selectedReturnId) {
143
+ returnChanged = false;
144
+ } else if (initialReturnDt && selectedReturnDt) {
145
+ // Some refreshes can rotate return availability IDs for the same departure time.
146
+ returnChanged =
147
+ parseAvailabilityDateTime(initialReturnDt).getTime() !==
148
+ parseAvailabilityDateTime(selectedReturnDt).getTime();
149
+ } else {
150
+ returnChanged = true;
151
+ }
152
+ } else if (initialReturnDt && selectedReturnDt) {
153
+ returnChanged =
154
+ parseAvailabilityDateTime(initialReturnDt).getTime() !==
155
+ parseAvailabilityDateTime(selectedReturnDt).getTime();
156
+ } else if (implicitReturnBaselineId != null && selectedReturnId != null) {
157
+ returnChanged = implicitReturnBaselineId !== selectedReturnId;
158
+ }
159
+ }
160
+
161
+ return {
162
+ hasChangesFromInitial:
163
+ dateChanged ||
164
+ productChanged ||
165
+ optionChanged ||
166
+ pickupChanged ||
167
+ countsChanged ||
168
+ addOnsChanged ||
169
+ returnChanged,
170
+ // Operational gating ignores option-id noise and considers only user-visible booking deltas.
171
+ hasOperationalChangesFromInitial:
172
+ dateChanged ||
173
+ productChanged ||
174
+ pickupChanged ||
175
+ countsChanged ||
176
+ addOnsChanged ||
177
+ returnChanged,
178
+ dateChanged,
179
+ // Tickets line corresponds to product/option + ticket counts; add-ons and pickup affect itinerary, not the ticket label.
180
+ ticketsChanged: Boolean(productChanged || optionChanged || countsChanged),
181
+ productChanged,
182
+ optionChanged,
183
+ pickupChanged,
184
+ countsChanged,
185
+ addOnsChanged,
186
+ returnChanged,
187
+ };
188
+ }, [
189
+ initialValues,
190
+ selectedAvailability,
191
+ selectedReturnOption,
192
+ implicitReturnBaselineId,
193
+ pickupLocationId,
194
+ quantities,
195
+ addOnSelections,
196
+ initialAddOnMinQtyByKey,
197
+ changeFlowResolvedInitialProductOptionId,
198
+ productChanged,
199
+ ]);
200
+ }