@ticketboothapp/booking 1.2.123 → 1.2.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +651 -6202
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +154 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,449 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { formatInTimeZone } from 'date-fns-tz';
3
+ import type { Availability, ReturnOption } from '../../lib/booking-api';
4
+ import type { ChangeFlowProtectedReceiptPricing } from '../../lib/booking/change-flow-pricing';
5
+ import {
6
+ isParentProductId,
7
+ normalizeProductOptionIdForChangeFlow,
8
+ } from '../../lib/booking/product-option-id';
9
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
10
+ import type { Currency } from './CurrencySwitcher';
11
+ import {
12
+ getAvailabilityOptionId,
13
+ normalizeLineLabelForCompare,
14
+ normalizeTicketCategoryFromReceiptLabel,
15
+ parseAvailabilityDateTime,
16
+ } from './change-booking-flow-helpers';
17
+
18
+ interface UseChangeBookingReceiptFloorControllerOptions {
19
+ productId: string;
20
+ initialValues?: ChangeBookingFlowProps['initialValues'];
21
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
22
+ selectedDate: string | null;
23
+ selectedAvailability: Availability | null;
24
+ selectedReturnOption: ReturnOption | null;
25
+ companyTimezone: string;
26
+ changeFlowResolvedInitialProductOptionId: string | null;
27
+ applyReceiptPaidFloorsEnabled: boolean;
28
+ isCustomerSelfServeChange: boolean;
29
+ currency: Currency;
30
+ serverReturnOptionPriceByReturnAvailabilityId?: Record<string, number>;
31
+ }
32
+
33
+ export function useChangeBookingReceiptFloorController({
34
+ productId,
35
+ initialValues,
36
+ originalReceipt,
37
+ selectedDate,
38
+ selectedAvailability,
39
+ selectedReturnOption,
40
+ companyTimezone,
41
+ changeFlowResolvedInitialProductOptionId,
42
+ applyReceiptPaidFloorsEnabled,
43
+ isCustomerSelfServeChange,
44
+ currency,
45
+ serverReturnOptionPriceByReturnAvailabilityId,
46
+ }: UseChangeBookingReceiptFloorControllerOptions) {
47
+ /**
48
+ * Parent catalog product id (`p_...`) for minimum-paid receipt floors: explicit parent on the booking,
49
+ * otherwise the product loaded for this change session (booking payload may only carry `po_...`).
50
+ */
51
+ const changeFlowBookingParentProductIdForFloors = useMemo(() => {
52
+ const pid = initialValues?.productId?.trim();
53
+ if (pid && isParentProductId(pid)) return pid;
54
+ return productId.trim();
55
+ }, [initialValues?.productId, productId]);
56
+
57
+ const changeFlowApplyReceiptPaidFloors = useMemo(
58
+ () => applyReceiptPaidFloorsEnabled && changeFlowBookingParentProductIdForFloors === productId.trim(),
59
+ [applyReceiptPaidFloorsEnabled, changeFlowBookingParentProductIdForFloors, productId],
60
+ );
61
+
62
+ const changeFlowInitialTicketQtyByCategory = useMemo(() => {
63
+ const qtyByCategory = new Map<string, number>();
64
+ if (!initialValues?.bookingItems?.length) return qtyByCategory;
65
+ for (const item of initialValues.bookingItems) {
66
+ const category = item.category?.trim().toUpperCase();
67
+ if (!category) continue;
68
+ qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
69
+ }
70
+ return qtyByCategory;
71
+ }, [initialValues?.bookingItems]);
72
+
73
+ const changeFlowInitialTicketCount = useMemo(() => {
74
+ let sum = 0;
75
+ for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
76
+ return sum;
77
+ }, [changeFlowInitialTicketQtyByCategory]);
78
+
79
+ const changeFlowSeatCreditForOutboundAvailability = useCallback(
80
+ (availability: Availability): number => {
81
+ if (changeFlowInitialTicketCount <= 0) return 0;
82
+ const initialAvailabilityId = initialValues?.availabilityId?.trim();
83
+ const availabilityId = availability.availabilityId?.trim();
84
+ if (initialAvailabilityId && availabilityId) {
85
+ return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCount : 0;
86
+ }
87
+ if (!initialValues?.dateTime) return 0;
88
+ const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
89
+ const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
90
+ if (selectedMs !== initialMs) return 0;
91
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
92
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
93
+ if (selectedOpt != null && initialOpt != null) {
94
+ return selectedOpt === initialOpt ? changeFlowInitialTicketCount : 0;
95
+ }
96
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
97
+ const legacySelected = availability.productOptionId?.trim() || null;
98
+ if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCount;
99
+ return legacySelected === legacyInitial ? changeFlowInitialTicketCount : 0;
100
+ },
101
+ [
102
+ changeFlowInitialTicketCount,
103
+ changeFlowResolvedInitialProductOptionId,
104
+ initialValues?.availabilityId,
105
+ initialValues?.dateTime,
106
+ initialValues?.productOptionId,
107
+ ],
108
+ );
109
+
110
+ const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
111
+ (returnAvailabilityId: string | null | undefined): number => {
112
+ if (changeFlowInitialTicketCount <= 0) return 0;
113
+ const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
114
+ const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
115
+ if (!selectedReturnAvailabilityId) {
116
+ return initialReturnAvailabilityId == null ? changeFlowInitialTicketCount : 0;
117
+ }
118
+ if (!initialReturnAvailabilityId) return 0;
119
+ return selectedReturnAvailabilityId === initialReturnAvailabilityId
120
+ ? changeFlowInitialTicketCount
121
+ : 0;
122
+ },
123
+ [changeFlowInitialTicketCount, initialValues?.returnAvailabilityId],
124
+ );
125
+
126
+ const getCalendarEffectiveOutboundVacancies = useCallback(
127
+ (avail: Availability) =>
128
+ Math.max(0, avail.vacancies ?? 0) + changeFlowSeatCreditForOutboundAvailability(avail),
129
+ [changeFlowSeatCreditForOutboundAvailability],
130
+ );
131
+
132
+ const changeFlowTicketBookedUnitPriceByCategory = useMemo(() => {
133
+ if (!originalReceipt?.lineItems?.length) return new Map<string, number>();
134
+ const amountByCategory = new Map<string, number>();
135
+ const qtyByCategory = new Map<string, number>();
136
+ for (const line of originalReceipt.lineItems) {
137
+ const type = (line.type || '').trim().toUpperCase();
138
+ if (type !== 'TICKET') continue;
139
+ const category = normalizeTicketCategoryFromReceiptLabel(line.label || '');
140
+ const amount = Number(line.amount ?? 0);
141
+ const qty = Number(line.quantity ?? 0);
142
+ if (!category || !Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
143
+ amountByCategory.set(category, (amountByCategory.get(category) ?? 0) + amount);
144
+ qtyByCategory.set(category, (qtyByCategory.get(category) ?? 0) + qty);
145
+ }
146
+ const floors = new Map<string, number>();
147
+ for (const [category, qty] of qtyByCategory.entries()) {
148
+ const totalAmount = amountByCategory.get(category) ?? 0;
149
+ if (totalAmount > 0 && qty > 0) floors.set(category, totalAmount / qty);
150
+ }
151
+ return floors;
152
+ }, [originalReceipt?.lineItems]);
153
+
154
+ /**
155
+ * When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
156
+ * so catalog "free" return slots still show and price at the original return value in change flow.
157
+ */
158
+ const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
159
+ if (!originalReceipt?.lineItems?.length) return null;
160
+ let totalAmount = 0;
161
+ let totalQty = 0;
162
+ for (const line of originalReceipt.lineItems) {
163
+ const type = (line.type || '').trim().toUpperCase();
164
+ if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
165
+ const amount = Number(line.amount ?? 0);
166
+ const qtyRaw = Number(line.quantity ?? 0);
167
+ const qty = qtyRaw > 0 ? qtyRaw : changeFlowInitialTicketCount;
168
+ if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
169
+ totalAmount += amount;
170
+ totalQty += qty;
171
+ }
172
+ if (totalAmount <= 0 || totalQty <= 0) return null;
173
+ return totalAmount / totalQty;
174
+ }, [originalReceipt?.lineItems, changeFlowInitialTicketCount]);
175
+
176
+ const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
177
+ const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
178
+ if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
179
+ const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
180
+ if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
181
+ return null;
182
+ }, [initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
183
+
184
+ const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
185
+ const feeUnitByLabel = new Map<string, number>();
186
+ if (!originalReceipt?.lineItems?.length) return feeUnitByLabel;
187
+ for (const line of originalReceipt.lineItems) {
188
+ const type = (line.type || '').trim().toUpperCase();
189
+ if (!line.label || type === 'TICKET' || type === 'RETURN_OPTION' || type === 'TAX') continue;
190
+ if (type.includes('PROMO') || type.includes('VOUCHER') || type.includes('GIFT')) continue;
191
+ const amount = Number(line.amount ?? 0);
192
+ const qtyRaw = Number(line.quantity ?? 0);
193
+ const qty = qtyRaw > 0 ? qtyRaw : changeFlowInitialTicketCount;
194
+ if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
195
+ const key = normalizeLineLabelForCompare(line.label);
196
+ if (!key) continue;
197
+ feeUnitByLabel.set(key, amount / qty);
198
+ }
199
+ return feeUnitByLabel;
200
+ }, [originalReceipt?.lineItems, changeFlowInitialTicketCount]);
201
+
202
+ const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
203
+ if (!selectedAvailability || !initialValues?.dateTime) return false;
204
+ const initialAvailabilityId = initialValues.availabilityId?.trim();
205
+ const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
206
+ const idsMatch =
207
+ Boolean(initialAvailabilityId && selectedAvailabilityId) &&
208
+ initialAvailabilityId === selectedAvailabilityId;
209
+ if (idsMatch) return true;
210
+ const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
211
+ const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
212
+ if (selectedMs !== initialMs) return false;
213
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(getAvailabilityOptionId(selectedAvailability));
214
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
215
+ if (selectedOpt != null && initialOpt != null) {
216
+ return selectedOpt === initialOpt;
217
+ }
218
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
219
+ const legacySelected = getAvailabilityOptionId(selectedAvailability) || null;
220
+ return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
221
+ }, [
222
+ selectedAvailability,
223
+ initialValues?.availabilityId,
224
+ initialValues?.dateTime,
225
+ initialValues?.productOptionId,
226
+ changeFlowResolvedInitialProductOptionId,
227
+ ]);
228
+
229
+ /**
230
+ * Rule A (exact locked ticket/fee units): same calendar departure day and same product option as the booking.
231
+ * A different departure time on that day with the same PO still counts as unchanged.
232
+ */
233
+ const changeFlowTicketPricingUnchangedItinerary = useMemo(() => {
234
+ if (!selectedAvailability || !initialValues?.dateTime?.trim()) return false;
235
+ let initialDay: string;
236
+ try {
237
+ initialDay = formatInTimeZone(
238
+ parseAvailabilityDateTime(initialValues.dateTime.trim()),
239
+ companyTimezone,
240
+ 'yyyy-MM-dd',
241
+ );
242
+ } catch {
243
+ return false;
244
+ }
245
+ if (!selectedDate || selectedDate !== initialDay) return false;
246
+ const selectedOpt = normalizeProductOptionIdForChangeFlow(getAvailabilityOptionId(selectedAvailability));
247
+ const initialOpt = changeFlowResolvedInitialProductOptionId;
248
+ if (selectedOpt != null && initialOpt != null) {
249
+ return selectedOpt === initialOpt;
250
+ }
251
+ const legacyInitial = initialValues.productOptionId?.trim() || null;
252
+ const legacySelected = getAvailabilityOptionId(selectedAvailability) || null;
253
+ return Boolean(legacySelected && legacyInitial && legacySelected === legacyInitial);
254
+ }, [
255
+ selectedDate,
256
+ selectedAvailability,
257
+ initialValues?.dateTime,
258
+ initialValues?.productOptionId,
259
+ companyTimezone,
260
+ changeFlowResolvedInitialProductOptionId,
261
+ ]);
262
+
263
+ const changeFlowProtectedReceiptPricing = useMemo((): ChangeFlowProtectedReceiptPricing => {
264
+ return changeFlowTicketPricingUnchangedItinerary ? 'exact_from_receipt' : 'max_with_catalog';
265
+ }, [changeFlowTicketPricingUnchangedItinerary]);
266
+
267
+ const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
268
+ const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
269
+ const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
270
+ const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
271
+ const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
272
+
273
+ if (!selectedReturnAvailabilityId) {
274
+ return initialReturnAvailabilityId == null;
275
+ }
276
+ if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
277
+ if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
278
+ if (initialReturnDt && selectedReturnDt) {
279
+ return (
280
+ parseAvailabilityDateTime(initialReturnDt).getTime() ===
281
+ parseAvailabilityDateTime(selectedReturnDt).getTime()
282
+ );
283
+ }
284
+ return false;
285
+ }
286
+ if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
287
+ return (
288
+ parseAvailabilityDateTime(initialReturnDt).getTime() ===
289
+ parseAvailabilityDateTime(selectedReturnDt).getTime()
290
+ );
291
+ }
292
+ return false;
293
+ }, [
294
+ initialValues?.returnAvailabilityId,
295
+ initialValues?.returnDateTime,
296
+ selectedReturnOption?.returnAvailabilityId,
297
+ selectedReturnOption?.dateTime,
298
+ ]);
299
+
300
+ /**
301
+ * Mirrors BE `returnPricingRuleA`: receipt floor exists, outbound itinerary is unchanged, and the selected return id
302
+ * exactly equals the booking return id.
303
+ */
304
+ const changeFlowReturnPricingRuleA = useMemo(() => {
305
+ if (!changeFlowApplyReceiptPaidFloors) return false;
306
+ if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return false;
307
+ if (!changeFlowTicketPricingUnchangedItinerary) return false;
308
+ const booked = initialValues?.returnAvailabilityId?.trim() || null;
309
+ const selected = selectedReturnOption?.returnAvailabilityId?.trim() || null;
310
+ return Boolean(booked && selected && booked === selected);
311
+ }, [
312
+ changeFlowApplyReceiptPaidFloors,
313
+ effectiveChangeFlowReturnUnitFloorPerPerson,
314
+ changeFlowTicketPricingUnchangedItinerary,
315
+ initialValues?.returnAvailabilityId,
316
+ selectedReturnOption?.returnAvailabilityId,
317
+ ]);
318
+
319
+ const returnOptionsWithFloor = useMemo(() => {
320
+ const options = selectedAvailability?.returnOptions ?? [];
321
+ const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
322
+ const applyReturnFloor = floor != null && isCustomerSelfServeChange;
323
+ return options.map((opt) => {
324
+ const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
325
+ const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
326
+ const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
327
+ const flooredPerPerson = applyReturnFloor ? Math.max(rawPerPerson, floor) : rawPerPerson;
328
+ let perPerson = flooredPerPerson;
329
+ if (isCustomerSelfServeChange && serverReturnOptionPriceByReturnAvailabilityId && opt.returnAvailabilityId) {
330
+ const sid = opt.returnAvailabilityId.trim();
331
+ const sp = serverReturnOptionPriceByReturnAvailabilityId[sid];
332
+ if (sp != null && Number.isFinite(sp)) {
333
+ perPerson = sp;
334
+ }
335
+ }
336
+ if (applyReturnFloor && floor != null) {
337
+ perPerson = Math.max(perPerson, floor);
338
+ }
339
+ if (perPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
340
+ return {
341
+ ...opt,
342
+ vacancies: adjustedVacancies,
343
+ priceAdjustmentByCurrency: {
344
+ ...(opt.priceAdjustmentByCurrency ?? {}),
345
+ [currency]: perPerson,
346
+ },
347
+ };
348
+ });
349
+ }, [
350
+ selectedAvailability?.returnOptions,
351
+ effectiveChangeFlowReturnUnitFloorPerPerson,
352
+ isCustomerSelfServeChange,
353
+ currency,
354
+ changeFlowSeatCreditForReturnAvailabilityId,
355
+ serverReturnOptionPriceByReturnAvailabilityId,
356
+ ]);
357
+
358
+ const selectedReturnOptionWithFloor = useMemo(() => {
359
+ if (!selectedReturnOption) return selectedReturnOption;
360
+ const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
361
+ const applyReturnFloor = floor != null && isCustomerSelfServeChange;
362
+ const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
363
+ let perPerson = rawPerPerson;
364
+ if (
365
+ isCustomerSelfServeChange &&
366
+ serverReturnOptionPriceByReturnAvailabilityId &&
367
+ selectedReturnOption.returnAvailabilityId
368
+ ) {
369
+ const sid = selectedReturnOption.returnAvailabilityId.trim();
370
+ const sp = serverReturnOptionPriceByReturnAvailabilityId[sid];
371
+ if (sp != null && Number.isFinite(sp)) {
372
+ perPerson = sp;
373
+ }
374
+ }
375
+ if (applyReturnFloor && floor != null) {
376
+ perPerson = Math.max(perPerson, floor);
377
+ }
378
+ if (perPerson === rawPerPerson) return selectedReturnOption;
379
+ return {
380
+ ...selectedReturnOption,
381
+ priceAdjustmentByCurrency: {
382
+ ...(selectedReturnOption.priceAdjustmentByCurrency ?? {}),
383
+ [currency]: perPerson,
384
+ },
385
+ };
386
+ }, [
387
+ selectedReturnOption,
388
+ effectiveChangeFlowReturnUnitFloorPerPerson,
389
+ isCustomerSelfServeChange,
390
+ currency,
391
+ serverReturnOptionPriceByReturnAvailabilityId,
392
+ ]);
393
+
394
+ const effectiveSelectedPickupVacancies = useMemo(() => {
395
+ if (!selectedAvailability) return 0;
396
+ const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
397
+ return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
398
+ }, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
399
+
400
+ const effectiveSelectedReturnVacancies = useMemo(() => {
401
+ if (!selectedReturnOption) return null;
402
+ const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
403
+ selectedReturnOption.returnAvailabilityId
404
+ );
405
+ return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
406
+ }, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
407
+
408
+ /** Round-trip party limit: both legs must fit, using the tighter of outbound vs return vacancies. */
409
+ const effectivePartySizeCap = useMemo(() => {
410
+ if (!selectedAvailability) return 0;
411
+ const outboundSeatCredit =
412
+ changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
413
+ ? changeFlowInitialTicketCount
414
+ : 0;
415
+ const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
416
+ if (selectedReturnOption == null) return outbound;
417
+ const returnSeatCredit =
418
+ changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
419
+ ? changeFlowInitialTicketCount
420
+ : 0;
421
+ const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
422
+ return Math.min(outbound, returnCap);
423
+ }, [
424
+ selectedAvailability,
425
+ selectedReturnOption,
426
+ changeFlowInitialTicketCount,
427
+ changeFlowOutboundMatchesOriginalSelection,
428
+ changeFlowReturnMatchesOriginalSelection,
429
+ ]);
430
+
431
+ return {
432
+ changeFlowApplyReceiptPaidFloors,
433
+ changeFlowSeatCreditForOutboundAvailability,
434
+ changeFlowTicketBookedUnitPriceByCategory,
435
+ effectiveChangeFlowReturnUnitFloorPerPerson,
436
+ changeFlowBookedFeeUnitByNormalizedLabel,
437
+ changeFlowInitialTicketQtyByCategory,
438
+ changeFlowInitialTicketCount,
439
+ changeFlowProtectedReceiptPricing,
440
+ changeFlowReturnPricingRuleA,
441
+ returnOptionsWithFloor,
442
+ selectedReturnOptionWithFloor,
443
+ returnOptionForOrderSummary: selectedReturnOptionWithFloor,
444
+ effectiveSelectedPickupVacancies,
445
+ effectiveSelectedReturnVacancies,
446
+ effectivePartySizeCap,
447
+ getCalendarEffectiveOutboundVacancies,
448
+ };
449
+ }