@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,461 @@
1
+ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+ import {
3
+ cancelReservation,
4
+ cancelReservationBestEffort,
5
+ confirmFreeChangeBooking,
6
+ createChangeBookingPaymentIntent,
7
+ describeStandardTourCapacityConflictMessage,
8
+ isInsufficientCapacityReserveError,
9
+ reportReserveCapacityConflictClientContext,
10
+ type Availability,
11
+ type PricingConfig,
12
+ type Product,
13
+ type ReturnOption,
14
+ } from '../../lib/booking-api';
15
+ import type { OrderSummary } from '../../lib/booking/pricing';
16
+ import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
17
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
18
+ import { quoteCustomerChangeForCheckout } from './change-booking-checkout-quote-runner';
19
+ import {
20
+ buildChangeBookingPaidCheckoutModalData,
21
+ type ChangeBookingPaidCheckoutModalData,
22
+ } from './change-booking-payment-modal-builders';
23
+ import type { ChangeBookingMergedQuoteState } from './change-booking-quote-state';
24
+ import {
25
+ findMergedAvailabilityForSelection,
26
+ findMergedReturnVacancies,
27
+ getAvailabilityOptionId,
28
+ } from './change-booking-flow-helpers';
29
+ import type { CheckoutModalLineItem } from './CheckoutModal';
30
+ import type { Currency } from './CurrencySwitcher';
31
+
32
+ type BookingItem = { category: string; count: number };
33
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
34
+
35
+ export interface UseChangeBookingCheckoutControllerParams {
36
+ selectedAvailability: Availability | null;
37
+ selectedReturnOption: ReturnOption | null;
38
+ setSelectedAvailability: Dispatch<SetStateAction<Availability | null>>;
39
+ setSelectedReturnOption: Dispatch<SetStateAction<ReturnOption | null>>;
40
+ product: Product;
41
+ quantities: Record<string, number>;
42
+ totalQuantity: number;
43
+ missingRequiredReturnSelection: boolean;
44
+ isPublicSelectableAvailability: (availability: Availability, now?: Date) => boolean;
45
+ email: string;
46
+ firstName: string;
47
+ lastName: string;
48
+ pickupLocationId: string | null;
49
+ pickupLocationSkipped: boolean;
50
+ hidePickupSkipOption: boolean;
51
+ t: (key: string) => string;
52
+ currency: Currency;
53
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
54
+ initialValues: ChangeBookingFlowProps['initialValues'];
55
+ addOnSelections: AddOnSelection[];
56
+ latestChangeQuote: ChangeBookingMergedQuoteState | null;
57
+ setLatestChangeQuote: Dispatch<SetStateAction<ChangeBookingMergedQuoteState | null>>;
58
+ changeFlowNewBookingTotal: number;
59
+ effectiveSubtotal: number;
60
+ effectiveTax: number;
61
+ onSuccess?: ChangeBookingFlowProps['onSuccess'];
62
+ totalPrice: number;
63
+ checkoutModalTicketLinesDisplay: CheckoutModalLineItem[];
64
+ feeLineItemsWithAddOns: OrderSummary['feeLineItems'];
65
+ checkoutReturnLineAmount: number;
66
+ cancellationPolicyFee: number;
67
+ effectiveCancellationPolicyLabel: string;
68
+ isTaxIncludedInPrice: boolean;
69
+ effectivePromoDiscountAmount: number;
70
+ tax: number;
71
+ appliedPromoCode: string | null;
72
+ pricingConfig: PricingConfig | null;
73
+ reloadAvailabilitiesAfterReserveConflict: () => Promise<Availability[]>;
74
+ selectedDate: string;
75
+ setLoading: (loading: boolean) => void;
76
+ setError: (error: string) => void;
77
+ }
78
+
79
+ export interface UseChangeBookingCheckoutControllerResult {
80
+ showCheckoutModal: boolean;
81
+ checkoutClientSecret: string;
82
+ checkoutModalData: ChangeBookingPaidCheckoutModalData | null;
83
+ cancelPendingReservation: () => void;
84
+ handleCheckout: () => Promise<void>;
85
+ handlePaymentSubmitStart: () => void;
86
+ handlePaymentSubmitError: () => void;
87
+ }
88
+
89
+ function bookingItemsFromQuantities(quantities: Record<string, number>): BookingItem[] {
90
+ return Object.entries(quantities)
91
+ .filter(([, count]) => count > 0)
92
+ .map(([category, count]) => ({ category, count }));
93
+ }
94
+
95
+ function updatePendingChangeBookingSession({
96
+ totalPrice,
97
+ currency,
98
+ selectedAvailability,
99
+ }: {
100
+ totalPrice: number;
101
+ currency: Currency;
102
+ selectedAvailability: Availability;
103
+ }): void {
104
+ try {
105
+ const storedBooking = sessionStorage.getItem('pendingBooking');
106
+ if (!storedBooking) return;
107
+ const booking = JSON.parse(storedBooking);
108
+ booking.totalPrice = totalPrice;
109
+ booking.currency = currency || selectedAvailability.currency || 'CAD';
110
+ sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
111
+ } catch (e) {
112
+ console.warn('Failed to update booking data', e);
113
+ }
114
+ }
115
+
116
+ export function useChangeBookingCheckoutController({
117
+ selectedAvailability,
118
+ selectedReturnOption,
119
+ setSelectedAvailability,
120
+ setSelectedReturnOption,
121
+ product,
122
+ quantities,
123
+ totalQuantity,
124
+ missingRequiredReturnSelection,
125
+ isPublicSelectableAvailability,
126
+ email,
127
+ firstName,
128
+ lastName,
129
+ pickupLocationId,
130
+ pickupLocationSkipped,
131
+ hidePickupSkipOption,
132
+ t,
133
+ currency,
134
+ originalReceipt,
135
+ initialValues,
136
+ addOnSelections,
137
+ latestChangeQuote,
138
+ setLatestChangeQuote,
139
+ changeFlowNewBookingTotal,
140
+ effectiveSubtotal,
141
+ effectiveTax,
142
+ onSuccess,
143
+ totalPrice,
144
+ checkoutModalTicketLinesDisplay,
145
+ feeLineItemsWithAddOns,
146
+ checkoutReturnLineAmount,
147
+ cancellationPolicyFee,
148
+ effectiveCancellationPolicyLabel,
149
+ isTaxIncludedInPrice,
150
+ effectivePromoDiscountAmount,
151
+ tax,
152
+ appliedPromoCode,
153
+ pricingConfig,
154
+ reloadAvailabilitiesAfterReserveConflict,
155
+ selectedDate,
156
+ setLoading,
157
+ setError,
158
+ }: UseChangeBookingCheckoutControllerParams): UseChangeBookingCheckoutControllerResult {
159
+ const [showCheckoutModal, setShowCheckoutModal] = useState(false);
160
+ const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
161
+ const [checkoutModalData, setCheckoutModalData] = useState<ChangeBookingPaidCheckoutModalData | null>(null);
162
+ const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
163
+ const paymentSubmitInFlightRef = useRef(false);
164
+
165
+ const cancelPendingReservation = useCallback(() => {
166
+ if (paymentSubmitInFlightRef.current) return;
167
+ const pending = pendingReservationRef.current;
168
+ if (pending) {
169
+ pendingReservationRef.current = null;
170
+ cancelReservation(pending.reservationReference).catch(() => {});
171
+ }
172
+ setShowCheckoutModal(false);
173
+ setCheckoutModalData(null);
174
+ setCheckoutClientSecret('');
175
+ }, []);
176
+
177
+ const cancelPendingReservationBestEffort = useCallback(() => {
178
+ if (paymentSubmitInFlightRef.current) return;
179
+ const pending = pendingReservationRef.current;
180
+ if (pending) {
181
+ pendingReservationRef.current = null;
182
+ cancelReservationBestEffort(pending.reservationReference);
183
+ }
184
+ setShowCheckoutModal(false);
185
+ setCheckoutModalData(null);
186
+ setCheckoutClientSecret('');
187
+ }, []);
188
+
189
+ useEffect(() => {
190
+ const handleAbandon = () => {
191
+ cancelPendingReservation();
192
+ };
193
+ window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
194
+ return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
195
+ }, [cancelPendingReservation]);
196
+
197
+ useEffect(() => {
198
+ const handlePageHide = () => {
199
+ cancelPendingReservationBestEffort();
200
+ };
201
+ window.addEventListener('pagehide', handlePageHide);
202
+ return () => window.removeEventListener('pagehide', handlePageHide);
203
+ }, [cancelPendingReservationBestEffort]);
204
+
205
+ const handleCheckout = useCallback(async () => {
206
+ if (!selectedAvailability || totalQuantity === 0) {
207
+ setError(t('booking.selectTimeAndTickets'));
208
+ return;
209
+ }
210
+ if (!isPublicSelectableAvailability(selectedAvailability, new Date())) {
211
+ setSelectedAvailability(null);
212
+ setSelectedReturnOption(null);
213
+ setError('This departure is no longer available online because it starts too soon. Please choose a later time.');
214
+ return;
215
+ }
216
+ if (missingRequiredReturnSelection) {
217
+ setError('Removing return option in self-serve is not available. Please contact support.');
218
+ return;
219
+ }
220
+
221
+ if (!email) {
222
+ setError(t('booking.enterEmail') || 'Please enter your email address');
223
+ return;
224
+ }
225
+
226
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
227
+ setError(t('booking.invalidEmail') || 'Please enter a valid email address');
228
+ return;
229
+ }
230
+
231
+ if (!firstName?.trim()) {
232
+ setError(t('booking.enterFirstName') || 'Please enter your first name');
233
+ return;
234
+ }
235
+
236
+ if (!lastName?.trim()) {
237
+ setError(t('booking.enterLastName') || 'Please enter your last name');
238
+ return;
239
+ }
240
+
241
+ if (
242
+ product.pickupLocations &&
243
+ product.pickupLocations.length > 0 &&
244
+ !pickupLocationId &&
245
+ (!pickupLocationSkipped || hidePickupSkipOption)
246
+ ) {
247
+ setError(t('booking.selectPickupLocation'));
248
+ return;
249
+ }
250
+
251
+ setLoading(true);
252
+ setError('');
253
+ paymentSubmitInFlightRef.current = false;
254
+
255
+ try {
256
+ const bookingItems = bookingItemsFromQuantities(quantities);
257
+ const availabilityProductOptionId = getAvailabilityOptionId(selectedAvailability);
258
+
259
+ if (!availabilityProductOptionId) {
260
+ setError('No product option selected');
261
+ setLoading(false);
262
+ return;
263
+ }
264
+
265
+ const quoteResult = await quoteCustomerChangeForCheckout({
266
+ bookingReference: initialValues?.bookingReference,
267
+ lastName,
268
+ availabilityProductOptionId,
269
+ dateTime: selectedAvailability.dateTime,
270
+ availabilityId: selectedAvailability.availabilityId || null,
271
+ pickupLocationId: pickupLocationId || null,
272
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
273
+ bookingItems,
274
+ addOnSelections,
275
+ clientProposedTotal: latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
276
+ fallbackCart: {
277
+ total: changeFlowNewBookingTotal,
278
+ subtotal: effectiveSubtotal,
279
+ tax: effectiveTax,
280
+ },
281
+ currency,
282
+ originalReceiptTotal: originalReceipt?.total ?? 0,
283
+ });
284
+ setLatestChangeQuote(quoteResult.mergedQuote);
285
+ if (quoteResult.decision.kind === 'free') {
286
+ const freeConfirm = await confirmFreeChangeBooking(quoteResult.decision.changeIntentId);
287
+ if (freeConfirm.status && freeConfirm.status !== 'APPLIED') {
288
+ throw new Error('We could not apply this booking change yet. Please try again.');
289
+ }
290
+ onSuccess?.({ reservationReference: quoteResult.bookingReference });
291
+ setLoading(false);
292
+ return;
293
+ }
294
+
295
+ pendingReservationRef.current = null;
296
+ updatePendingChangeBookingSession({
297
+ totalPrice,
298
+ currency,
299
+ selectedAvailability,
300
+ });
301
+
302
+ const datePart = selectedAvailability.dateTime.split('T')[0];
303
+ const amountDueForCheckout = quoteResult.decision.amountDueForCheckout;
304
+ const paymentIntent = await createChangeBookingPaymentIntent(
305
+ quoteResult.decision.changeIntentId
306
+ );
307
+
308
+ setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
309
+ setCheckoutModalData(
310
+ buildChangeBookingPaidCheckoutModalData({
311
+ bookingReference: quoteResult.bookingReference,
312
+ lastName,
313
+ datePart,
314
+ ticketLines: checkoutModalTicketLinesDisplay,
315
+ feeLineItems: feeLineItemsWithAddOns,
316
+ returnPriceAdjustment: checkoutReturnLineAmount,
317
+ cancellationPolicyFee,
318
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
319
+ subtotal: effectiveSubtotal,
320
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
321
+ total: amountDueForCheckout,
322
+ totalQuantity,
323
+ isTaxIncludedInPrice,
324
+ taxRate: pricingConfig?.taxRate ?? 0,
325
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
326
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
327
+ previousTotal: originalReceipt?.total,
328
+ newTotal: originalReceipt
329
+ ? quoteResult.confirmedNewTotalForCheckout
330
+ : undefined,
331
+ successUrl: {
332
+ enabled: true,
333
+ bookingReference: quoteResult.bookingReference,
334
+ lastName,
335
+ datePart,
336
+ changeIntentId: quoteResult.decision.changeIntentId,
337
+ },
338
+ })
339
+ );
340
+ setShowCheckoutModal(true);
341
+ setLoading(false);
342
+ } catch (err) {
343
+ if (isInsufficientCapacityReserveError(err)) {
344
+ try {
345
+ const merged = await reloadAvailabilitiesAfterReserveConflict();
346
+ const outbound = findMergedAvailabilityForSelection(merged, selectedAvailability);
347
+ const outboundVacancies = outbound?.vacancies ?? null;
348
+ const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
349
+ setError(
350
+ describeStandardTourCapacityConflictMessage({
351
+ partySize: totalQuantity,
352
+ outboundVacancies,
353
+ returnVacancies,
354
+ hasReturnSelection: !!selectedReturnOption,
355
+ })
356
+ );
357
+ reportReserveCapacityConflictClientContext({
358
+ flow: 'standard_tour',
359
+ productId: product.productId,
360
+ selectedDate: selectedDate || null,
361
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
362
+ outboundVacanciesAfterRefresh: outboundVacancies,
363
+ returnVacanciesAfterRefresh: returnVacancies,
364
+ partySizeOrPassengers: totalQuantity,
365
+ hasReturnSelection: !!selectedReturnOption,
366
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
367
+ reloadAvailabilitiesSucceeded: true,
368
+ });
369
+ } catch (reloadErr) {
370
+ setError(
371
+ describeStandardTourCapacityConflictMessage({
372
+ partySize: totalQuantity,
373
+ outboundVacancies: null,
374
+ returnVacancies: null,
375
+ hasReturnSelection: !!selectedReturnOption,
376
+ })
377
+ );
378
+ reportReserveCapacityConflictClientContext({
379
+ flow: 'standard_tour',
380
+ productId: product.productId,
381
+ selectedDate: selectedDate || null,
382
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
383
+ outboundVacanciesAfterRefresh: null,
384
+ returnVacanciesAfterRefresh: null,
385
+ partySizeOrPassengers: totalQuantity,
386
+ hasReturnSelection: !!selectedReturnOption,
387
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
388
+ reloadAvailabilitiesSucceeded: false,
389
+ reloadErrorMessage:
390
+ reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
391
+ });
392
+ }
393
+ setLoading(false);
394
+ return;
395
+ }
396
+ setError(err instanceof Error ? err.message : 'Something went wrong');
397
+ setLoading(false);
398
+ }
399
+ }, [
400
+ selectedAvailability,
401
+ totalQuantity,
402
+ setError,
403
+ t,
404
+ isPublicSelectableAvailability,
405
+ setSelectedAvailability,
406
+ setSelectedReturnOption,
407
+ missingRequiredReturnSelection,
408
+ email,
409
+ firstName,
410
+ lastName,
411
+ product.pickupLocations,
412
+ product.productId,
413
+ pickupLocationId,
414
+ pickupLocationSkipped,
415
+ hidePickupSkipOption,
416
+ setLoading,
417
+ quantities,
418
+ initialValues?.bookingReference,
419
+ addOnSelections,
420
+ latestChangeQuote?.serverDisplay?.total,
421
+ changeFlowNewBookingTotal,
422
+ effectiveSubtotal,
423
+ effectiveTax,
424
+ currency,
425
+ originalReceipt,
426
+ setLatestChangeQuote,
427
+ onSuccess,
428
+ totalPrice,
429
+ checkoutModalTicketLinesDisplay,
430
+ feeLineItemsWithAddOns,
431
+ checkoutReturnLineAmount,
432
+ cancellationPolicyFee,
433
+ effectiveCancellationPolicyLabel,
434
+ effectivePromoDiscountAmount,
435
+ tax,
436
+ isTaxIncludedInPrice,
437
+ pricingConfig?.taxRate,
438
+ appliedPromoCode,
439
+ reloadAvailabilitiesAfterReserveConflict,
440
+ selectedReturnOption,
441
+ selectedDate,
442
+ ]);
443
+
444
+ const handlePaymentSubmitStart = useCallback(() => {
445
+ paymentSubmitInFlightRef.current = true;
446
+ }, []);
447
+
448
+ const handlePaymentSubmitError = useCallback(() => {
449
+ paymentSubmitInFlightRef.current = false;
450
+ }, []);
451
+
452
+ return {
453
+ showCheckoutModal,
454
+ checkoutClientSecret,
455
+ checkoutModalData,
456
+ cancelPendingReservation,
457
+ handleCheckout,
458
+ handlePaymentSubmitStart,
459
+ handlePaymentSubmitError,
460
+ };
461
+ }
@@ -0,0 +1,204 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
4
+ import { formatInTimeZone } from 'date-fns-tz';
5
+ import type { AddOn, Availability, PrecomputedPricesByCategory } from '../../lib/booking-api';
6
+ import type { Currency } from './CurrencySwitcher';
7
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
8
+ import {
9
+ deriveAddOnSelectionsFromReceiptLines,
10
+ normalizeAddOnSelections,
11
+ parseAvailabilityDateTime,
12
+ resolveInitialAvailabilityFromBooking,
13
+ type AddOnSelection,
14
+ } from './change-booking-flow-helpers';
15
+
16
+ export interface UseChangeBookingInitialHydrationOptions {
17
+ initialValues?: ChangeBookingFlowProps['initialValues'];
18
+ lockedPromoCode: string | null;
19
+ appliedPromoCode: string | null;
20
+ promoCodeInput: string;
21
+ selectedAvailability: Availability | null;
22
+ selectedDate: string;
23
+ timesForSelectedDate: Availability[];
24
+ companyTimezone: string;
25
+ changeFlowResolvedInitialProductOptionId: string | null;
26
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined;
27
+ currency: Currency;
28
+ originalReceiptSubtotal?: number | null;
29
+ originalReceiptLineItems?: NonNullable<ChangeBookingFlowProps['originalReceipt']>['lineItems'];
30
+ addOns: AddOn[];
31
+ addOnSelections: AddOnSelection[];
32
+ setEmail: Dispatch<SetStateAction<string>>;
33
+ setFirstName: Dispatch<SetStateAction<string>>;
34
+ setLastName: Dispatch<SetStateAction<string>>;
35
+ setPickupLocationId: Dispatch<SetStateAction<string | null>>;
36
+ setPickupLocationSkipped: Dispatch<SetStateAction<boolean>>;
37
+ setPromoCodeInput: Dispatch<SetStateAction<string>>;
38
+ setAppliedPromoCode: Dispatch<SetStateAction<string | null>>;
39
+ setSelectedDate: (date: string) => void;
40
+ setSelectedAvailability: Dispatch<SetStateAction<Availability | null>>;
41
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
42
+ setAddOnSelections: Dispatch<SetStateAction<AddOnSelection[]>>;
43
+ setError: Dispatch<SetStateAction<string>>;
44
+ }
45
+
46
+ export function useChangeBookingInitialHydration({
47
+ initialValues,
48
+ lockedPromoCode,
49
+ appliedPromoCode,
50
+ promoCodeInput,
51
+ selectedAvailability,
52
+ selectedDate,
53
+ timesForSelectedDate,
54
+ companyTimezone,
55
+ changeFlowResolvedInitialProductOptionId,
56
+ precomputedPricesByOption,
57
+ currency,
58
+ originalReceiptSubtotal,
59
+ originalReceiptLineItems,
60
+ addOns,
61
+ addOnSelections,
62
+ setEmail,
63
+ setFirstName,
64
+ setLastName,
65
+ setPickupLocationId,
66
+ setPickupLocationSkipped,
67
+ setPromoCodeInput,
68
+ setAppliedPromoCode,
69
+ setSelectedDate,
70
+ setSelectedAvailability,
71
+ setQuantities,
72
+ setAddOnSelections,
73
+ setError,
74
+ }: UseChangeBookingInitialHydrationOptions) {
75
+ const hasAppliedInitialValuesRef = useRef(false);
76
+ const hasAppliedInitialQuantitiesRef = useRef(false);
77
+ const hasHydratedAddOnsFromReceiptRef = useRef(false);
78
+
79
+ useEffect(() => {
80
+ if (hasAppliedInitialValuesRef.current || !initialValues) return;
81
+ const trimmedEmail = initialValues.customer?.email?.trim();
82
+ const trimmedFirstName = initialValues.customer?.firstName?.trim();
83
+ const trimmedLastName = initialValues.customer?.lastName?.trim();
84
+ const trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
85
+ if (trimmedEmail) setEmail(trimmedEmail);
86
+ if (trimmedFirstName) setFirstName(trimmedFirstName);
87
+ if (trimmedLastName) setLastName(trimmedLastName);
88
+ if (initialValues.pickupLocationId?.trim()) {
89
+ setPickupLocationId(initialValues.pickupLocationId.trim());
90
+ setPickupLocationSkipped(false);
91
+ }
92
+ if (trimmedPromo) {
93
+ setPromoCodeInput(trimmedPromo);
94
+ setAppliedPromoCode(trimmedPromo);
95
+ }
96
+ hasAppliedInitialValuesRef.current = true;
97
+ }, [
98
+ initialValues,
99
+ setAppliedPromoCode,
100
+ setEmail,
101
+ setFirstName,
102
+ setLastName,
103
+ setPickupLocationId,
104
+ setPickupLocationSkipped,
105
+ setPromoCodeInput,
106
+ ]);
107
+
108
+ useEffect(() => {
109
+ if (!lockedPromoCode) return;
110
+ if (appliedPromoCode !== lockedPromoCode) setAppliedPromoCode(lockedPromoCode);
111
+ if (promoCodeInput !== lockedPromoCode) setPromoCodeInput(lockedPromoCode);
112
+ }, [lockedPromoCode, appliedPromoCode, promoCodeInput, setAppliedPromoCode, setPromoCodeInput]);
113
+
114
+ useEffect(() => {
115
+ if (!initialValues?.dateTime || selectedAvailability) return;
116
+ const target = parseAvailabilityDateTime(initialValues.dateTime);
117
+ const targetDate = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
118
+ if (!selectedDate) {
119
+ setSelectedDate(targetDate);
120
+ return;
121
+ }
122
+ if (selectedDate !== targetDate || timesForSelectedDate.length === 0) return;
123
+ const { selection, defer } = resolveInitialAvailabilityFromBooking(
124
+ timesForSelectedDate,
125
+ target,
126
+ companyTimezone,
127
+ initialValues.availabilityId ?? null,
128
+ changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
129
+ initialValues.bookingItems ?? null,
130
+ precomputedPricesByOption,
131
+ currency,
132
+ originalReceiptSubtotal ?? undefined,
133
+ );
134
+ if (defer) return;
135
+ if (selection) {
136
+ setSelectedAvailability(selection);
137
+ setError('');
138
+ }
139
+ }, [
140
+ initialValues?.dateTime,
141
+ initialValues?.availabilityId,
142
+ initialValues?.productOptionId,
143
+ initialValues?.bookingItems,
144
+ changeFlowResolvedInitialProductOptionId,
145
+ selectedAvailability,
146
+ selectedDate,
147
+ timesForSelectedDate,
148
+ companyTimezone,
149
+ precomputedPricesByOption,
150
+ currency,
151
+ originalReceiptSubtotal,
152
+ setSelectedDate,
153
+ setSelectedAvailability,
154
+ setError,
155
+ ]);
156
+
157
+ useEffect(() => {
158
+ if (
159
+ hasAppliedInitialQuantitiesRef.current ||
160
+ !initialValues?.bookingItems ||
161
+ initialValues.bookingItems.length === 0 ||
162
+ !selectedAvailability
163
+ ) {
164
+ return;
165
+ }
166
+ const next: Record<string, number> = {};
167
+ for (const item of initialValues.bookingItems) {
168
+ const key = item.category?.trim();
169
+ if (!key) continue;
170
+ next[key] = Math.max(0, Number(item.count) || 0);
171
+ }
172
+ if (Object.keys(next).length > 0) {
173
+ setQuantities((prev) => ({ ...prev, ...next }));
174
+ hasAppliedInitialQuantitiesRef.current = true;
175
+ }
176
+ }, [initialValues, selectedAvailability, setQuantities]);
177
+
178
+ useEffect(() => {
179
+ if (!initialValues?.addOnSelections?.length) return;
180
+ setAddOnSelections((prev) =>
181
+ prev.length > 0 ? prev : normalizeAddOnSelections(initialValues.addOnSelections!),
182
+ );
183
+ }, [initialValues?.addOnSelections, setAddOnSelections]);
184
+
185
+ useEffect(() => {
186
+ if (hasHydratedAddOnsFromReceiptRef.current) return;
187
+ if ((initialValues?.addOnSelections?.length ?? 0) > 0) return;
188
+ if (addOnSelections.length > 0) return;
189
+ if (addOns.length === 0) return;
190
+ const receiptLines = originalReceiptLineItems ?? [];
191
+ if (receiptLines.length === 0) return;
192
+ const derived = deriveAddOnSelectionsFromReceiptLines(addOns, receiptLines);
193
+ hasHydratedAddOnsFromReceiptRef.current = true;
194
+ if (derived.length > 0) {
195
+ setAddOnSelections(normalizeAddOnSelections(derived));
196
+ }
197
+ }, [
198
+ initialValues?.addOnSelections,
199
+ addOnSelections.length,
200
+ addOns,
201
+ originalReceiptLineItems,
202
+ setAddOnSelections,
203
+ ]);
204
+ }