@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,442 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ type Dispatch,
6
+ type SetStateAction,
7
+ } from 'react';
8
+ import {
9
+ quoteChangeBooking,
10
+ type Availability,
11
+ type ItineraryDisplayStep,
12
+ type Product,
13
+ type ReturnOption,
14
+ } from '../../lib/booking-api';
15
+ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
16
+ import type { OrderSummaryTicketLine } from '../../lib/booking/pricing';
17
+ import { formatCurrencyAmount } from '../../lib/currency';
18
+ import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
19
+ import {
20
+ sliceAndMergeChangeQuoteForUi,
21
+ type ChangeBookingMergedQuoteState,
22
+ } from './change-booking-quote-state';
23
+ import {
24
+ formatTicketLineItemsForSummary,
25
+ getAvailabilityOptionId,
26
+ } from './change-booking-flow-helpers';
27
+ import type { Currency } from './CurrencySwitcher';
28
+
29
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
30
+
31
+ interface ChangeSelectionDetailsForPreview {
32
+ hasChangesFromInitial: boolean;
33
+ dateChanged: boolean;
34
+ ticketsChanged: boolean;
35
+ }
36
+
37
+ export interface UseChangeBookingQuotePreviewParams {
38
+ hasChangeSelection: boolean;
39
+ hasEffectiveChangeSelection: boolean;
40
+ selectedAvailability: Availability | null;
41
+ selectedReturnOption: ReturnOption | null;
42
+ initialValues: ChangeBookingFlowProps['initialValues'];
43
+ lastName: string;
44
+ product: Product;
45
+ pickupLocationId: string | null;
46
+ quantities: Record<string, number>;
47
+ addOnSelections: AddOnSelection[];
48
+ latestChangeQuote: ChangeBookingMergedQuoteState | null;
49
+ setLatestChangeQuote: Dispatch<SetStateAction<ChangeBookingMergedQuoteState | null>>;
50
+ changeQuoteLoading: boolean;
51
+ setChangeQuoteLoading: Dispatch<SetStateAction<boolean>>;
52
+ changeQuoteFetchError: string | null;
53
+ setChangeQuoteFetchError: Dispatch<SetStateAction<string | null>>;
54
+ totalQuantity: number;
55
+ ticketLineItems: OrderSummaryTicketLine[];
56
+ computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
57
+ changeSelectionDetails: ChangeSelectionDetailsForPreview;
58
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
59
+ suppressSelfServeCurrencyUi: boolean;
60
+ selfServePricingConfirmed: boolean;
61
+ displayChangeFlowProposedTotal: number;
62
+ displayChangeFlowSubtotal: number;
63
+ displayChangeFlowTax: number;
64
+ totalPrice: number;
65
+ effectiveSubtotal: number;
66
+ effectiveTax: number;
67
+ currency: Currency;
68
+ changeFlowClientEstimateDue: number;
69
+ changeFlowInitialTicketCount: number;
70
+ changeFlowNewBookingTotal: number;
71
+ isTaxIncludedInPrice: boolean;
72
+ isPublicSelectableAvailability: (availability: Availability, now?: Date) => boolean;
73
+ t: (key: string) => string;
74
+ locale: string;
75
+ error: string;
76
+ onChangeFlowSelectionPreview?: ChangeBookingFlowProps['onChangeFlowSelectionPreview'];
77
+ onPricePreviewChange?: ChangeBookingFlowProps['onPricePreviewChange'];
78
+ }
79
+
80
+ export interface UseChangeBookingQuotePreviewResult {
81
+ missingRequiredReturnSelection: boolean;
82
+ isChangeQuoteBlocked: boolean;
83
+ changeFlowSubmitDisabled: boolean;
84
+ changeCheckoutButtonLabel: string | undefined;
85
+ checkoutFormError: string;
86
+ }
87
+
88
+ export function useChangeBookingQuotePreview({
89
+ hasChangeSelection,
90
+ hasEffectiveChangeSelection,
91
+ selectedAvailability,
92
+ selectedReturnOption,
93
+ initialValues,
94
+ lastName,
95
+ product,
96
+ pickupLocationId,
97
+ quantities,
98
+ addOnSelections,
99
+ latestChangeQuote,
100
+ setLatestChangeQuote,
101
+ changeQuoteLoading,
102
+ setChangeQuoteLoading,
103
+ changeQuoteFetchError,
104
+ setChangeQuoteFetchError,
105
+ totalQuantity,
106
+ ticketLineItems,
107
+ computeItineraryDisplay,
108
+ changeSelectionDetails,
109
+ originalReceipt,
110
+ suppressSelfServeCurrencyUi,
111
+ selfServePricingConfirmed,
112
+ displayChangeFlowProposedTotal,
113
+ displayChangeFlowSubtotal,
114
+ displayChangeFlowTax,
115
+ totalPrice,
116
+ effectiveSubtotal,
117
+ effectiveTax,
118
+ currency,
119
+ changeFlowClientEstimateDue,
120
+ changeFlowInitialTicketCount,
121
+ changeFlowNewBookingTotal,
122
+ isTaxIncludedInPrice,
123
+ isPublicSelectableAvailability,
124
+ t,
125
+ locale,
126
+ error,
127
+ onChangeFlowSelectionPreview,
128
+ onPricePreviewChange,
129
+ }: UseChangeBookingQuotePreviewParams): UseChangeBookingQuotePreviewResult {
130
+ const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
131
+ const lastCompletedQuoteInputsKeyRef = useRef<string | null>(null);
132
+ const inFlightQuoteInputsKeyRef = useRef<string | null>(null);
133
+ const changeQuoteRequestSeq = useRef(0);
134
+
135
+ const changeFlowNeedsServerPrice =
136
+ hasChangeSelection &&
137
+ !!initialValues?.bookingReference?.trim() &&
138
+ !!lastName.trim();
139
+
140
+ const isChangeQuoteBlocked = latestChangeQuote?.canProceed === false;
141
+ const requiresReturnInChangeFlow = !!initialValues?.returnAvailabilityId?.trim();
142
+ const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
143
+ const changeQuoteInputsKey = useMemo(() => JSON.stringify({
144
+ bookingReference: initialValues?.bookingReference?.trim() ?? '',
145
+ lastName: lastName.trim().toLowerCase(),
146
+ parentProductId: product.productId,
147
+ optionId: selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : '',
148
+ dateTime: selectedAvailability?.dateTime ?? '',
149
+ availabilityId: selectedAvailability?.availabilityId ?? null,
150
+ pickupLocationId: pickupLocationId ?? null,
151
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
152
+ quantities,
153
+ addOnSelections,
154
+ }), [
155
+ initialValues?.bookingReference,
156
+ lastName,
157
+ product.productId,
158
+ selectedAvailability,
159
+ pickupLocationId,
160
+ selectedReturnOption?.returnAvailabilityId,
161
+ quantities,
162
+ addOnSelections,
163
+ ]);
164
+
165
+ const changeFlowSubmitDisabled =
166
+ missingRequiredReturnSelection ||
167
+ (changeFlowNeedsServerPrice &&
168
+ (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
169
+
170
+ const changeCheckoutButtonLabel = (() => {
171
+ if (!hasEffectiveChangeSelection) return undefined;
172
+ if (changeFlowNeedsServerPrice) {
173
+ if (changeQuoteLoading) {
174
+ const tr = t('booking.updatingPrice');
175
+ return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
176
+ }
177
+ if (changeQuoteFetchError) {
178
+ const tr = t('booking.changeBookingRetry');
179
+ return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
180
+ }
181
+ if (isChangeQuoteBlocked) {
182
+ const tr = t('booking.changeBookingNotAvailable');
183
+ return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
184
+ }
185
+ if (latestChangeQuote) {
186
+ const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
187
+ return d > 0
188
+ ? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
189
+ : 'Change booking (no charge)';
190
+ }
191
+ const tr = t('booking.changeBooking');
192
+ return tr !== 'booking.changeBooking' ? tr : 'Change booking';
193
+ }
194
+ const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
195
+ return est > 0
196
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
197
+ : 'Change booking (no charge)';
198
+ })();
199
+
200
+ const checkoutFormError =
201
+ (error || '') ||
202
+ (missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
203
+ (isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
204
+ (changeQuoteFetchError ?? '');
205
+
206
+ const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
207
+ if (!selectedAvailability || totalQuantity <= 0) return null;
208
+ const ticketsLine = formatTicketLineItemsForSummary(
209
+ ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
210
+ );
211
+ const itinerary = computeItineraryDisplay();
212
+ const itinerarySteps =
213
+ itinerary?.map((s) => ({
214
+ time: s.time?.trim() || null,
215
+ label: getItineraryStepLabel(s),
216
+ })) ?? [];
217
+ return {
218
+ tourName: product.name?.trim() || '',
219
+ dateTime: selectedAvailability.dateTime,
220
+ ticketsLine,
221
+ itinerarySteps,
222
+ dateChanged: changeSelectionDetails.dateChanged,
223
+ ticketsChanged: changeSelectionDetails.ticketsChanged,
224
+ hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
225
+ selectionTotal:
226
+ originalReceipt
227
+ ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
228
+ ? null
229
+ : displayChangeFlowProposedTotal
230
+ : totalPrice,
231
+ selectionCurrency: currency,
232
+ };
233
+ }, [
234
+ selectedAvailability,
235
+ totalQuantity,
236
+ ticketLineItems,
237
+ computeItineraryDisplay,
238
+ product.name,
239
+ changeSelectionDetails,
240
+ totalPrice,
241
+ currency,
242
+ originalReceipt,
243
+ displayChangeFlowProposedTotal,
244
+ suppressSelfServeCurrencyUi,
245
+ selfServePricingConfirmed,
246
+ ]);
247
+
248
+ useEffect(() => {
249
+ if (!onChangeFlowSelectionPreview) return;
250
+ const next = changeFlowSelectionPreview;
251
+ const key =
252
+ next === null
253
+ ? 'null'
254
+ : JSON.stringify({
255
+ tourName: next.tourName,
256
+ dateTime: next.dateTime,
257
+ ticketsLine: next.ticketsLine,
258
+ itinerarySteps: next.itinerarySteps,
259
+ dateChanged: next.dateChanged,
260
+ ticketsChanged: next.ticketsChanged,
261
+ hasChangesFromInitial: next.hasChangesFromInitial,
262
+ selectionTotal: next.selectionTotal,
263
+ selectionCurrency: next.selectionCurrency,
264
+ });
265
+ if (key === lastChangeFlowPreviewKeyRef.current) return;
266
+ lastChangeFlowPreviewKeyRef.current = key;
267
+ onChangeFlowSelectionPreview(next);
268
+ }, [changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
269
+
270
+ useEffect(() => {
271
+ if (!onPricePreviewChange) return;
272
+ if (!selectedAvailability || totalQuantity <= 0) {
273
+ onPricePreviewChange(null);
274
+ return;
275
+ }
276
+ if (suppressSelfServeCurrencyUi && !selfServePricingConfirmed) {
277
+ onPricePreviewChange(null);
278
+ return;
279
+ }
280
+ onPricePreviewChange({
281
+ subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
282
+ tax:
283
+ !isTaxIncludedInPrice
284
+ ? originalReceipt
285
+ ? displayChangeFlowTax
286
+ : effectiveTax
287
+ : 0,
288
+ total: originalReceipt ? displayChangeFlowProposedTotal : totalPrice,
289
+ currency,
290
+ });
291
+ }, [
292
+ onPricePreviewChange,
293
+ selectedAvailability,
294
+ totalQuantity,
295
+ effectiveSubtotal,
296
+ effectiveTax,
297
+ displayChangeFlowProposedTotal,
298
+ displayChangeFlowSubtotal,
299
+ displayChangeFlowTax,
300
+ currency,
301
+ isTaxIncludedInPrice,
302
+ originalReceipt,
303
+ totalPrice,
304
+ suppressSelfServeCurrencyUi,
305
+ selfServePricingConfirmed,
306
+ ]);
307
+
308
+ /** Debounced server quote so CTA + amount owed match PaymentIntent; avoids free confirm when FE estimate differs. */
309
+ useEffect(() => {
310
+ if (
311
+ !hasChangeSelection ||
312
+ !selectedAvailability ||
313
+ !initialValues?.bookingReference?.trim() ||
314
+ !lastName.trim() ||
315
+ missingRequiredReturnSelection
316
+ ) {
317
+ setLatestChangeQuote(null);
318
+ setChangeQuoteLoading(false);
319
+ setChangeQuoteFetchError(null);
320
+ lastCompletedQuoteInputsKeyRef.current = null;
321
+ inFlightQuoteInputsKeyRef.current = null;
322
+ return;
323
+ }
324
+
325
+ if (
326
+ changeQuoteInputsKey &&
327
+ (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
328
+ lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
329
+ ) {
330
+ setChangeQuoteLoading(false);
331
+ return;
332
+ }
333
+
334
+ const optionId = getAvailabilityOptionId(selectedAvailability);
335
+ if (!optionId) {
336
+ setLatestChangeQuote(null);
337
+ setChangeQuoteLoading(false);
338
+ setChangeQuoteFetchError(null);
339
+ lastCompletedQuoteInputsKeyRef.current = null;
340
+ inFlightQuoteInputsKeyRef.current = null;
341
+ return;
342
+ }
343
+ if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
344
+ setLatestChangeQuote(null);
345
+ setChangeQuoteLoading(false);
346
+ setChangeQuoteFetchError(null);
347
+ lastCompletedQuoteInputsKeyRef.current = null;
348
+ inFlightQuoteInputsKeyRef.current = null;
349
+ return;
350
+ }
351
+
352
+ if (changeQuoteInputsKey && lastCompletedQuoteInputsKeyRef.current !== changeQuoteInputsKey) {
353
+ setLatestChangeQuote(null);
354
+ }
355
+ setChangeQuoteLoading(true);
356
+ setChangeQuoteFetchError(null);
357
+ const seq = ++changeQuoteRequestSeq.current;
358
+ const bookingReferenceForQuote = initialValues.bookingReference.trim();
359
+ const timer = window.setTimeout(() => {
360
+ void (async () => {
361
+ inFlightQuoteInputsKeyRef.current = changeQuoteInputsKey;
362
+ const bookingItems = Object.entries(quantities)
363
+ .filter(([, count]) => count > 0)
364
+ .map(([category, count]) => ({ category, count }));
365
+ try {
366
+ const quote = await quoteChangeBooking({
367
+ bookingReference: bookingReferenceForQuote,
368
+ lastName: lastName.trim(),
369
+ newProductId: optionId,
370
+ newDateTime: selectedAvailability.dateTime,
371
+ newAvailabilityId: selectedAvailability.availabilityId || null,
372
+ newPickupLocationId: pickupLocationId || null,
373
+ newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
374
+ newPassengerCounts: bookingItems,
375
+ // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
376
+ ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
377
+ clientProposedTotal: changeFlowNewBookingTotal,
378
+ capacitySeatCredit: {
379
+ enabled: true,
380
+ previousPassengerCount: changeFlowInitialTicketCount,
381
+ previousAvailabilityId: initialValues.availabilityId ?? null,
382
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
383
+ },
384
+ });
385
+ if (seq !== changeQuoteRequestSeq.current) return;
386
+ setLatestChangeQuote(
387
+ sliceAndMergeChangeQuoteForUi(quote, {
388
+ total: changeFlowNewBookingTotal,
389
+ subtotal: effectiveSubtotal,
390
+ tax: effectiveTax,
391
+ }, currency).mergedQuote,
392
+ );
393
+ lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
394
+ } catch (e) {
395
+ if (seq !== changeQuoteRequestSeq.current) return;
396
+ setLatestChangeQuote(null);
397
+ setChangeQuoteFetchError(e instanceof Error ? e.message : 'Failed to get price for this change');
398
+ lastCompletedQuoteInputsKeyRef.current = null;
399
+ } finally {
400
+ if (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
401
+ inFlightQuoteInputsKeyRef.current = null;
402
+ }
403
+ if (seq === changeQuoteRequestSeq.current) {
404
+ setChangeQuoteLoading(false);
405
+ }
406
+ }
407
+ })();
408
+ }, 450);
409
+
410
+ return () => {
411
+ window.clearTimeout(timer);
412
+ };
413
+ }, [
414
+ hasChangeSelection,
415
+ selectedAvailability,
416
+ initialValues,
417
+ lastName,
418
+ missingRequiredReturnSelection,
419
+ pickupLocationId,
420
+ selectedReturnOption?.returnAvailabilityId,
421
+ quantities,
422
+ addOnSelections,
423
+ changeFlowInitialTicketCount,
424
+ changeFlowNewBookingTotal,
425
+ effectiveSubtotal,
426
+ effectiveTax,
427
+ currency,
428
+ isPublicSelectableAvailability,
429
+ changeQuoteInputsKey,
430
+ setLatestChangeQuote,
431
+ setChangeQuoteLoading,
432
+ setChangeQuoteFetchError,
433
+ ]);
434
+
435
+ return {
436
+ missingRequiredReturnSelection,
437
+ isChangeQuoteBlocked,
438
+ changeFlowSubmitDisabled,
439
+ changeCheckoutButtonLabel,
440
+ checkoutFormError,
441
+ };
442
+ }
@@ -0,0 +1,128 @@
1
+ import { useMemo } from 'react';
2
+ import {
3
+ changeFlowBalanceVsOriginal,
4
+ normalizeNearZeroOwed,
5
+ reconcileTaxIncludedChangeFlowTicketLines,
6
+ resolveChangeFlowDisplayedAmounts,
7
+ roundMoney,
8
+ } from '../../lib/booking/change-flow-pricing';
9
+ import type { PriceBreakdown as PriceBreakdownData } from '../../lib/booking/pricing';
10
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
11
+ import type { ChangeBookingMergedQuoteState } from './change-booking-quote-state';
12
+ import type { CheckoutModalLineItem } from './CheckoutModal';
13
+ import type { PriceSummaryLine } from './PriceSummary';
14
+ import type { PricingRate } from './change-booking-flow-helpers';
15
+
16
+ type GetPriceBreakdown = (
17
+ category: string,
18
+ priceCAD: number,
19
+ baseInDisplayCurrency: number | undefined,
20
+ appliedAdjustments?: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
21
+ ) => PriceBreakdownData | null;
22
+
23
+ export interface UseChangeBookingReceiptDerivationParams {
24
+ latestChangeQuote: ChangeBookingMergedQuoteState | null;
25
+ changeQuoteFetchError: string | null;
26
+ changeFlowNewBookingTotal: number;
27
+ effectiveSubtotal: number;
28
+ effectiveTax: number;
29
+ isTaxIncludedInPrice: boolean;
30
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
31
+ checkoutPriceSummaryLinesForCheckout: PriceSummaryLine[];
32
+ effectivePromoDiscountAmount: number;
33
+ pricing: PricingRate[];
34
+ getPriceBreakdown: GetPriceBreakdown;
35
+ totalPrice: number;
36
+ }
37
+
38
+ export function useChangeBookingReceiptDerivation({
39
+ latestChangeQuote,
40
+ changeQuoteFetchError,
41
+ changeFlowNewBookingTotal,
42
+ effectiveSubtotal,
43
+ effectiveTax,
44
+ isTaxIncludedInPrice,
45
+ originalReceipt,
46
+ checkoutPriceSummaryLinesForCheckout,
47
+ effectivePromoDiscountAmount,
48
+ pricing,
49
+ getPriceBreakdown,
50
+ totalPrice,
51
+ }: UseChangeBookingReceiptDerivationParams) {
52
+ const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
53
+ providerPreview: null,
54
+ serverQuotePreview: latestChangeQuote?.serverDisplay ? latestChangeQuote.serverDisplay : null,
55
+ fromCart: {
56
+ total: changeFlowNewBookingTotal,
57
+ subtotal: effectiveSubtotal,
58
+ tax: effectiveTax,
59
+ },
60
+ });
61
+ const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
62
+ const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
63
+ const displayChangeFlowTax = displayedChangeAmounts.tax;
64
+
65
+ const taxIncludedSelfServeReconciledPriceSummaryLines = useMemo(() => {
66
+ if (!isTaxIncludedInPrice || !originalReceipt) {
67
+ return checkoutPriceSummaryLinesForCheckout;
68
+ }
69
+ return reconcileTaxIncludedChangeFlowTicketLines(
70
+ checkoutPriceSummaryLinesForCheckout,
71
+ displayChangeFlowProposedTotal,
72
+ effectivePromoDiscountAmount,
73
+ );
74
+ }, [
75
+ isTaxIncludedInPrice,
76
+ originalReceipt,
77
+ checkoutPriceSummaryLinesForCheckout,
78
+ displayChangeFlowProposedTotal,
79
+ effectivePromoDiscountAmount,
80
+ ]);
81
+
82
+ const checkoutModalTicketLinesDisplay = useMemo((): CheckoutModalLineItem[] => {
83
+ return taxIncludedSelfServeReconciledPriceSummaryLines
84
+ .filter((line): line is Extract<PriceSummaryLine, { kind: 'ticket' }> => line.kind === 'ticket')
85
+ .map((line) => {
86
+ const rate = pricing.find((item) => item.category === line.category);
87
+ const breakdown = getPriceBreakdown(
88
+ line.category,
89
+ rate?.priceCAD ?? 0,
90
+ rate?.baseInDisplayCurrency,
91
+ rate?.appliedAdjustments ?? [],
92
+ );
93
+ const qty = Math.max(0, line.qty);
94
+ return {
95
+ line: {
96
+ category: line.category,
97
+ qty,
98
+ itemTotal: line.itemTotal,
99
+ pricePerUnit: qty > 0 ? roundMoney(line.itemTotal / qty) : 0,
100
+ },
101
+ breakdown,
102
+ };
103
+ });
104
+ }, [taxIncludedSelfServeReconciledPriceSummaryLines, pricing, getPriceBreakdown]);
105
+
106
+ const changeFlowClientEstimateDue = (() => {
107
+ if (!originalReceipt) return totalPrice;
108
+ if (latestChangeQuote != null && !changeQuoteFetchError) {
109
+ return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
110
+ }
111
+ return changeFlowBalanceVsOriginal({
112
+ newTotal: displayChangeFlowProposedTotal,
113
+ originalReceiptTotal: originalReceipt.total,
114
+ audience: 'customer',
115
+ });
116
+ })();
117
+ const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowClientEstimateDue);
118
+
119
+ return {
120
+ displayChangeFlowProposedTotal,
121
+ displayChangeFlowSubtotal,
122
+ displayChangeFlowTax,
123
+ taxIncludedSelfServeReconciledPriceSummaryLines,
124
+ checkoutModalTicketLinesDisplay,
125
+ changeFlowClientEstimateDue,
126
+ changeFlowAmountDue,
127
+ };
128
+ }