@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,806 @@
1
+ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+ import {
3
+ cancelReservation,
4
+ cancelReservationBestEffort,
5
+ createChangeBookingPaymentIntent,
6
+ createPaymentIntent,
7
+ describeStandardTourCapacityConflictMessage,
8
+ isInsufficientCapacityReserveError,
9
+ reportReserveCapacityConflictClientContext,
10
+ type AddOn,
11
+ type AdminFeAuthoritativeReceipt,
12
+ type Availability,
13
+ type ChangeBookingQuotePricingDriftDetail,
14
+ type ChangeBookingQuoteTicketPricingTrace,
15
+ type ItineraryDisplayStep,
16
+ type PricingConfig,
17
+ type Product,
18
+ type ReturnOption,
19
+ } from '../../lib/booking-api';
20
+ import {
21
+ changeFlowBalanceVsOriginal,
22
+ } from '../../lib/booking/change-flow-pricing';
23
+ import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
24
+ import {
25
+ buildBookingSourceContext,
26
+ inferClientBookingSourceFromProductIds,
27
+ } from '../../lib/booking/source-metadata';
28
+ import { formatBookingRefForDisplay } from '../../lib/booking-ref';
29
+ import type { OrderSummary, OrderSummaryTicketLine } from '../../lib/booking/pricing';
30
+ import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
31
+ import { buildChangeBookingCheckoutBreakdown } from './change-booking-checkout-builders';
32
+ import type { ChangeBookingServerPreview } from './change-booking-quote-state';
33
+ import {
34
+ buildChangeBookingPaidCheckoutModalData,
35
+ type ChangeBookingSuccessUrlFactory,
36
+ } from './change-booking-payment-modal-builders';
37
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
38
+ import type { CheckoutModalLineItem } from './CheckoutModal';
39
+ import type { Currency } from './CurrencySwitcher';
40
+ import {
41
+ findMergedAvailabilityForSelection,
42
+ findMergedReturnVacancies,
43
+ getAvailabilityOptionId,
44
+ } from './change-booking-flow-helpers';
45
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
46
+ import {
47
+ bookingItemsFromQuantities,
48
+ buildAdminChangeProviderPayload,
49
+ type AdminChangeAddOnSelection,
50
+ type AdminChangeBookingItem,
51
+ type AdminChangeProviderAdditionalAdjustment,
52
+ type AdminChangeProviderLineOverride,
53
+ } from './admin-change-provider-payload';
54
+ import {
55
+ buildAdminChangePaymentChoiceData,
56
+ buildAdminChangePayNowCheckoutModalData,
57
+ confirmAdminChangeBookingWithoutPayment,
58
+ type AdminChangeCheckoutModalData,
59
+ type AdminChangePaymentChoiceData,
60
+ } from './admin-change-payment-choice-runner';
61
+ import {
62
+ confirmFreeAdminCustomerChange,
63
+ quoteAdminCustomerChangeForCheckout,
64
+ } from './admin-change-customer-quote-runner';
65
+
66
+ type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
67
+
68
+ export interface AdminChangeLatestQuote {
69
+ priceDiff: number;
70
+ currency: Currency;
71
+ canProceed: boolean;
72
+ reasonIfBlocked?: string;
73
+ changeIntentId?: string;
74
+ quoteId?: string;
75
+ pricingQuoteInputsHash?: string;
76
+ quoteStatus?: string;
77
+ quoteExpiresAt?: string;
78
+ pricingVersion?: string;
79
+ quotedTotal?: number;
80
+ paymentCreditTotal?: number;
81
+ serverDisplay?: { total: number; subtotal: number; tax: number };
82
+ quotePreviousTotalCents?: number;
83
+ quoteNewTotalCents?: number;
84
+ serverPreview: ChangeBookingServerPreview;
85
+ pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
86
+ ticketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
87
+ }
88
+
89
+ export interface UseAdminChangeCheckoutControllerParams {
90
+ selectedAvailability: Availability | null;
91
+ selectedReturnOption: ReturnOption | null;
92
+ product: Product;
93
+ quantities: Record<string, number>;
94
+ totalQuantity: number;
95
+ missingRequiredReturnSelection: boolean;
96
+ isProviderDashboardChange: boolean;
97
+ isChangeBookingContext: boolean;
98
+ isCustomerSelfServeChange: boolean;
99
+ isAdmin: boolean;
100
+ email: string;
101
+ firstName: string;
102
+ lastName: string;
103
+ pickupLocationId: string | null;
104
+ pickupLocationSkipped: boolean;
105
+ bookingSourceAttribution: Partial<BookingSourceMetadata>;
106
+ partnerPortalBooking: boolean;
107
+ bookingAppMode?: string;
108
+ t: (key: string) => string;
109
+ currency: Currency;
110
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
111
+ initialValues: ChangeBookingFlowProps['initialValues'];
112
+ addOnSelections: AdminChangeAddOnSelection[];
113
+ adminCustomLinesAsAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
114
+ useAdminFeAuthoritativeQuote: boolean;
115
+ adminFeAuthoritativeReceipt: AdminFeAuthoritativeReceipt;
116
+ changeFlowClientEstimateDue: number;
117
+ latestChangeQuote: AdminChangeLatestQuote | null;
118
+ setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
119
+ changeFlowNewBookingTotal: number;
120
+ effectiveSubtotalForCheckout: number;
121
+ effectiveTax: number;
122
+ changeFlowInitialTicketCount: number;
123
+ onChangeBooking?: ChangeBookingFlowProps['onChangeBooking'];
124
+ cancellationPolicyId: string | null;
125
+ appliedPromoCode: string | null;
126
+ displayChangeFlowProposedTotalWithEditableLines: number;
127
+ providerPricingOverrides: AdminChangeProviderLineOverride[];
128
+ mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
129
+ providerApplyAuthoritativeReceipt?: ProviderDashboardChangeBookingPayload['authoritativeReceipt'];
130
+ onSuccess?: ChangeBookingFlowProps['onSuccess'];
131
+ totalPrice: number;
132
+ computeItineraryDisplayForStorage: () => ItineraryDisplayStep[] | null;
133
+ computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
134
+ effectivePromoDiscountAmount: number;
135
+ tax: number;
136
+ ticketLineItemsForChangeFlowDisplay: OrderSummaryTicketLine[];
137
+ checkoutReturnLineAmount: number;
138
+ cancellationPolicyFee: number;
139
+ effectiveCancellationPolicyLabel: string;
140
+ addOns: AddOn[];
141
+ feeLineItems: OrderSummary['feeLineItems'];
142
+ adminCustomReceiptLines: AdminCustomReceiptLine[];
143
+ isTaxIncludedInPrice: boolean;
144
+ originalReceiptPromoAdjustment: {
145
+ amount: number;
146
+ label: string | null;
147
+ type: 'PROMO_CODE' | 'GIFT_CARD' | 'VOUCHER' | 'DISCOUNT';
148
+ };
149
+ effectivePromoLineType: string;
150
+ lockedPromoCode: string | null;
151
+ termsAcceptedAt: string | null;
152
+ skipConfirmationCommunications: boolean;
153
+ disableAutoCommunications: boolean;
154
+ checkoutModalTicketLinesDisplay: CheckoutModalLineItem[];
155
+ feeLineItemsWithAddOns: OrderSummary['feeLineItems'];
156
+ pricingConfig: PricingConfig | null;
157
+ getSuccessUrl?: ChangeBookingSuccessUrlFactory;
158
+ reloadAvailabilitiesAfterReserveConflict: () => Promise<Availability[]>;
159
+ selectedDate: string;
160
+ setLoading: (loading: boolean) => void;
161
+ setError: (error: string) => void;
162
+ onShowManage?: (params: { ref: string; lastName: string }) => void;
163
+ }
164
+
165
+ function updatePendingChangeBookingSession({
166
+ totalPrice,
167
+ currency,
168
+ selectedAvailability,
169
+ }: {
170
+ totalPrice: number;
171
+ currency: Currency;
172
+ selectedAvailability: Availability;
173
+ }): void {
174
+ try {
175
+ const storedBooking = sessionStorage.getItem('pendingBooking');
176
+ if (!storedBooking) return;
177
+ const booking = JSON.parse(storedBooking);
178
+ booking.totalPrice = totalPrice;
179
+ booking.currency = currency || selectedAvailability.currency || 'CAD';
180
+ sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
181
+ } catch (e) {
182
+ console.warn('Failed to update booking data', e);
183
+ }
184
+ }
185
+
186
+ export function useAdminChangeCheckoutController({
187
+ selectedAvailability,
188
+ selectedReturnOption,
189
+ product,
190
+ quantities,
191
+ totalQuantity,
192
+ missingRequiredReturnSelection,
193
+ isProviderDashboardChange,
194
+ isChangeBookingContext,
195
+ isCustomerSelfServeChange,
196
+ isAdmin,
197
+ email,
198
+ firstName,
199
+ lastName,
200
+ pickupLocationId,
201
+ pickupLocationSkipped,
202
+ bookingSourceAttribution,
203
+ partnerPortalBooking,
204
+ bookingAppMode,
205
+ t,
206
+ currency,
207
+ originalReceipt,
208
+ initialValues,
209
+ addOnSelections,
210
+ adminCustomLinesAsAdditionalAdjustments,
211
+ useAdminFeAuthoritativeQuote,
212
+ adminFeAuthoritativeReceipt,
213
+ changeFlowClientEstimateDue,
214
+ latestChangeQuote,
215
+ setLatestChangeQuote,
216
+ changeFlowNewBookingTotal,
217
+ effectiveSubtotalForCheckout,
218
+ effectiveTax,
219
+ changeFlowInitialTicketCount,
220
+ onChangeBooking,
221
+ cancellationPolicyId,
222
+ appliedPromoCode,
223
+ displayChangeFlowProposedTotalWithEditableLines,
224
+ providerPricingOverrides,
225
+ mergedProviderAdditionalAdjustments,
226
+ providerApplyAuthoritativeReceipt,
227
+ onSuccess,
228
+ totalPrice,
229
+ computeItineraryDisplayForStorage,
230
+ computeItineraryDisplay,
231
+ effectivePromoDiscountAmount,
232
+ tax,
233
+ ticketLineItemsForChangeFlowDisplay,
234
+ checkoutReturnLineAmount,
235
+ cancellationPolicyFee,
236
+ effectiveCancellationPolicyLabel,
237
+ addOns,
238
+ feeLineItems,
239
+ adminCustomReceiptLines,
240
+ isTaxIncludedInPrice,
241
+ originalReceiptPromoAdjustment,
242
+ effectivePromoLineType,
243
+ lockedPromoCode,
244
+ termsAcceptedAt,
245
+ skipConfirmationCommunications,
246
+ disableAutoCommunications,
247
+ checkoutModalTicketLinesDisplay,
248
+ feeLineItemsWithAddOns,
249
+ pricingConfig,
250
+ getSuccessUrl,
251
+ reloadAvailabilitiesAfterReserveConflict,
252
+ selectedDate,
253
+ setLoading,
254
+ setError,
255
+ onShowManage,
256
+ }: UseAdminChangeCheckoutControllerParams) {
257
+ const [showCheckoutModal, setShowCheckoutModal] = useState(false);
258
+ const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
259
+ const [checkoutModalData, setCheckoutModalData] = useState<AdminChangeCheckoutModalData | null>(null);
260
+ const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
261
+ const [adminChoiceData, setAdminChoiceData] = useState<AdminChangePaymentChoiceData | null>(null);
262
+ const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
263
+ const paymentSubmitInFlightRef = useRef(false);
264
+
265
+ const cancelPendingReservation = useCallback(() => {
266
+ if (paymentSubmitInFlightRef.current) return;
267
+ const pending = pendingReservationRef.current;
268
+ if (pending) {
269
+ pendingReservationRef.current = null;
270
+ cancelReservation(pending.reservationReference).catch(() => {});
271
+ }
272
+ setShowCheckoutModal(false);
273
+ setCheckoutModalData(null);
274
+ setCheckoutClientSecret('');
275
+ }, []);
276
+
277
+ const cancelPendingReservationBestEffort = useCallback(() => {
278
+ if (paymentSubmitInFlightRef.current) return;
279
+ const pending = pendingReservationRef.current;
280
+ if (pending) {
281
+ pendingReservationRef.current = null;
282
+ cancelReservationBestEffort(pending.reservationReference);
283
+ }
284
+ setShowCheckoutModal(false);
285
+ setCheckoutModalData(null);
286
+ setCheckoutClientSecret('');
287
+ }, []);
288
+
289
+ useEffect(() => {
290
+ const handleAbandon = () => {
291
+ cancelPendingReservation();
292
+ };
293
+ window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
294
+ return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
295
+ }, [cancelPendingReservation]);
296
+
297
+ useEffect(() => {
298
+ const handlePageHide = () => {
299
+ cancelPendingReservationBestEffort();
300
+ };
301
+ window.addEventListener('pagehide', handlePageHide);
302
+ return () => window.removeEventListener('pagehide', handlePageHide);
303
+ }, [cancelPendingReservationBestEffort]);
304
+
305
+ const buildProviderChangePayload = (
306
+ availabilityProductOptionId: string,
307
+ bookingItems: AdminChangeBookingItem[],
308
+ ): ProviderDashboardChangeBookingPayload | null => {
309
+ return buildAdminChangeProviderPayload({
310
+ selectedAvailability,
311
+ availabilityProductOptionId,
312
+ bookingItems,
313
+ product,
314
+ pickupLocationId,
315
+ selectedReturnOption,
316
+ addOnSelections,
317
+ cancellationPolicyId,
318
+ initialCancellationPolicyId: initialValues?.cancellationPolicyId ?? null,
319
+ appliedPromoCode,
320
+ newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
321
+ providerPricingOverrides,
322
+ mergedProviderAdditionalAdjustments,
323
+ providerApplyAuthoritativeReceipt,
324
+ previousPassengerCount: changeFlowInitialTicketCount,
325
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
326
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
327
+ });
328
+ };
329
+
330
+ const finishManageFlow = (bookingReference: string) => {
331
+ const ref = formatBookingRefForDisplay(bookingReference);
332
+ const trimmedLastName = lastName.trim();
333
+ if (onShowManage) {
334
+ onShowManage({ ref, lastName: trimmedLastName });
335
+ return;
336
+ }
337
+ const params = new URLSearchParams({
338
+ ref,
339
+ lastName: trimmedLastName,
340
+ booking_complete: '1',
341
+ });
342
+ window.location.href = `/manage-booking?${params.toString()}`;
343
+ };
344
+
345
+ const handleCheckout = async () => {
346
+ if (!selectedAvailability || totalQuantity === 0) {
347
+ setError(t('booking.selectTimeAndTickets'));
348
+ return;
349
+ }
350
+ if (missingRequiredReturnSelection) {
351
+ setError('Please select a return time for this product.');
352
+ return;
353
+ }
354
+ if (isProviderDashboardChange && !isChangeBookingContext) {
355
+ setError('Provider change checkout requires the original booking reference.');
356
+ return;
357
+ }
358
+
359
+ const skipContactFields = isProviderDashboardChange;
360
+ if (!skipContactFields) {
361
+ if (!email) {
362
+ setError(t('booking.enterEmail') || 'Please enter your email address');
363
+ return;
364
+ }
365
+
366
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
367
+ setError(t('booking.invalidEmail') || 'Please enter a valid email address');
368
+ return;
369
+ }
370
+
371
+ if (!firstName?.trim()) {
372
+ setError(t('booking.enterFirstName') || 'Please enter your first name');
373
+ return;
374
+ }
375
+
376
+ if (!lastName?.trim()) {
377
+ setError(t('booking.enterLastName') || 'Please enter your last name');
378
+ return;
379
+ }
380
+ }
381
+
382
+ if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
383
+ setError(t('booking.selectPickupLocation'));
384
+ return;
385
+ }
386
+
387
+ setLoading(true);
388
+ setError('');
389
+ paymentSubmitInFlightRef.current = false;
390
+
391
+ try {
392
+ const bookingItems = bookingItemsFromQuantities(quantities);
393
+ const availabilityProductOptionId = getAvailabilityOptionId(selectedAvailability);
394
+
395
+ if (!availabilityProductOptionId) {
396
+ setError('No product option selected');
397
+ setLoading(false);
398
+ return;
399
+ }
400
+
401
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
402
+ clientChannelSource: inferClientBookingSourceFromProductIds(
403
+ product.productId,
404
+ availabilityProductOptionId,
405
+ ),
406
+ forcePartnerPortalChannel: partnerPortalBooking,
407
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
408
+ });
409
+
410
+ const selectedPickupLocation = pickupLocationId
411
+ ? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
412
+ : null;
413
+ let changeIntentIdForCheckout: string | undefined;
414
+ let changeBookingReferenceForPaidFlow: string | undefined;
415
+ let confirmedChangeAmountDueForCheckout: number | null = null;
416
+
417
+ if (isCustomerSelfServeChange) {
418
+ const quoteResult = await quoteAdminCustomerChangeForCheckout({
419
+ bookingReference: initialValues?.bookingReference,
420
+ lastName,
421
+ parentProductId: product.productId,
422
+ availabilityProductOptionId,
423
+ selectedAvailability,
424
+ pickupLocationId: pickupLocationId || null,
425
+ selectedReturnOption,
426
+ bookingItems,
427
+ addOnSelections,
428
+ manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments,
429
+ useAdminFeAuthoritativeQuote,
430
+ adminFeAuthoritativeReceipt,
431
+ originalReceiptTotal: originalReceipt?.total ?? null,
432
+ changeFlowClientEstimateDue,
433
+ latestServerDisplayTotal: latestChangeQuote?.serverDisplay?.total ?? null,
434
+ changeFlowNewBookingTotal,
435
+ effectiveSubtotalForCheckout,
436
+ effectiveTax,
437
+ changeFlowInitialTicketCount,
438
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
439
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
440
+ currency,
441
+ });
442
+ changeBookingReferenceForPaidFlow = quoteResult.bookingReference;
443
+ setLatestChangeQuote(quoteResult.mergedQuote);
444
+ confirmedChangeAmountDueForCheckout = quoteResult.decision.amountDueForCheckout;
445
+
446
+ if (quoteResult.decision.kind === 'free') {
447
+ if (onChangeBooking) {
448
+ const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
449
+ if (!providerPayload) {
450
+ setError('No availability selected');
451
+ setLoading(false);
452
+ return;
453
+ }
454
+ await onChangeBooking(providerPayload);
455
+ onSuccess?.({ reservationReference: quoteResult.bookingReference });
456
+ setLoading(false);
457
+ return;
458
+ }
459
+
460
+ await confirmFreeAdminCustomerChange(quoteResult.decision.changeIntentId);
461
+ onSuccess?.({ reservationReference: quoteResult.bookingReference });
462
+ setLoading(false);
463
+ return;
464
+ }
465
+
466
+ changeIntentIdForCheckout = quoteResult.decision.changeIntentId;
467
+ }
468
+
469
+ pendingReservationRef.current = null;
470
+ updatePendingChangeBookingSession({
471
+ totalPrice,
472
+ currency,
473
+ selectedAvailability,
474
+ });
475
+
476
+ const datePart = selectedAvailability.dateTime.split('T')[0];
477
+ const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
478
+ const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
479
+ const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
480
+ const amountDueForCheckout = isCustomerSelfServeChange
481
+ ? confirmedChangeAmountDueForCheckout ??
482
+ Math.max(
483
+ 0,
484
+ changeFlowBalanceVsOriginal({
485
+ newTotal: displayChangeFlowProposedTotalWithEditableLines,
486
+ originalReceiptTotal: originalReceipt?.total ?? 0,
487
+ audience: 'admin',
488
+ }),
489
+ )
490
+ : totalPrice;
491
+ const checkoutBreakdown = buildChangeBookingCheckoutBreakdown({
492
+ ticketLineItems: ticketLineItemsForChangeFlowDisplay,
493
+ totalQuantity,
494
+ checkoutReturnLineAmount,
495
+ returnOptionLabel: t('booking.returnOption') || 'Return option',
496
+ personLabel: t('booking.person') || 'person',
497
+ peopleLabel: t('booking.people') || 'people',
498
+ cancellationPolicyFee,
499
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
500
+ addOnSelections,
501
+ addOns,
502
+ feeLineItems,
503
+ customReceiptLines: adminCustomReceiptLines,
504
+ isTaxIncludedInPrice,
505
+ taxForBreakdown,
506
+ taxLabel: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
507
+ effectivePromoDiscountAmount,
508
+ appliedPromoCode,
509
+ discountLabel:
510
+ originalReceiptPromoAdjustment.label ||
511
+ originalReceipt?.promoLabel ||
512
+ (t('booking.discount') || 'Discount'),
513
+ promoLineType: effectivePromoLineType,
514
+ amountDueForCheckout,
515
+ currency,
516
+ roundingLabel: t('booking.rounding') || 'Rounding',
517
+ });
518
+
519
+ const paymentIntent = isCustomerSelfServeChange
520
+ ? await createChangeBookingPaymentIntent(
521
+ (() => {
522
+ const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
523
+ if (!id) {
524
+ throw new Error('Missing change intent for payment.');
525
+ }
526
+ return id;
527
+ })(),
528
+ )
529
+ : await createPaymentIntent({
530
+ productId: product.productId,
531
+ optionId: availabilityProductOptionId,
532
+ date: datePart,
533
+ time: timePart,
534
+ quantity: totalQuantity,
535
+ customerEmail: email,
536
+ customerFirstName: firstName.trim() || undefined,
537
+ customerLastName: lastName.trim() || undefined,
538
+ currency,
539
+ travelerHotel: selectedPickupLocation?.name || undefined,
540
+ pickupLocationId: pickupLocationId || undefined,
541
+ itineraryDisplay: itineraryDisplay ?? undefined,
542
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
543
+ promoCode: (lockedPromoCode || appliedPromoCode) || undefined,
544
+ cancellationPolicyId: cancellationPolicyId || undefined,
545
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
546
+ checkoutBreakdown,
547
+ skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
548
+ disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
549
+ ...bookingSourceContext,
550
+ });
551
+
552
+ if (isAdmin) {
553
+ const adminReservationRef =
554
+ initialValues?.bookingReference?.trim() ??
555
+ changeBookingReferenceForPaidFlow ??
556
+ '';
557
+ if (!adminReservationRef) {
558
+ throw new Error('Missing reservation reference for admin payment flow');
559
+ }
560
+ setError('');
561
+ setAdminChoiceData(buildAdminChangePaymentChoiceData({
562
+ reservationReference: adminReservationRef,
563
+ checkoutBreakdown,
564
+ totalAmount: amountDueForCheckout,
565
+ datePart,
566
+ timePart,
567
+ availabilityProductOptionId,
568
+ itineraryDisplay: itineraryDisplay ?? undefined,
569
+ clientSecret: paymentIntent.clientSecret ?? '',
570
+ ticketLinesForModal: checkoutModalTicketLinesDisplay,
571
+ feeLineItems: feeLineItemsWithAddOns,
572
+ returnPriceAdjustment: checkoutReturnLineAmount,
573
+ cancellationPolicyFee,
574
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
575
+ subtotal: effectiveSubtotalForCheckout,
576
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
577
+ totalQuantity,
578
+ isTaxIncludedInPrice,
579
+ taxRate: pricingConfig?.taxRate ?? 0,
580
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
581
+ discountLabel: appliedPromoCode
582
+ ? `Promo: ${appliedPromoCode}`
583
+ : (originalReceiptPromoAdjustment.label || originalReceipt?.promoLabel || undefined),
584
+ }));
585
+ setShowAdminPaymentChoice(true);
586
+ setLoading(false);
587
+ return;
588
+ }
589
+
590
+ setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
591
+ setCheckoutModalData(
592
+ buildChangeBookingPaidCheckoutModalData({
593
+ bookingReference: changeBookingReferenceForPaidFlow,
594
+ lastName,
595
+ datePart,
596
+ ticketLines: checkoutModalTicketLinesDisplay,
597
+ feeLineItems: feeLineItemsWithAddOns,
598
+ returnPriceAdjustment: checkoutReturnLineAmount,
599
+ cancellationPolicyFee,
600
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
601
+ subtotal: effectiveSubtotalForCheckout,
602
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
603
+ total: amountDueForCheckout,
604
+ totalQuantity,
605
+ isTaxIncludedInPrice,
606
+ taxRate: pricingConfig?.taxRate ?? 0,
607
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
608
+ discountLabel: appliedPromoCode
609
+ ? `Promo: ${appliedPromoCode}`
610
+ : (originalReceiptPromoAdjustment.label || originalReceipt?.promoLabel || undefined),
611
+ previousTotal: isCustomerSelfServeChange ? originalReceipt?.total : undefined,
612
+ newTotal:
613
+ isCustomerSelfServeChange && originalReceipt
614
+ ? displayChangeFlowProposedTotalWithEditableLines
615
+ : undefined,
616
+ successUrl: {
617
+ enabled: isCustomerSelfServeChange,
618
+ bookingReference: changeBookingReferenceForPaidFlow,
619
+ lastName,
620
+ datePart,
621
+ changeIntentId: changeIntentIdForCheckout,
622
+ preferHostSuccessUrl: bookingAppMode === 'provider-dashboard',
623
+ getSuccessUrl,
624
+ },
625
+ }),
626
+ );
627
+ setShowCheckoutModal(true);
628
+ setLoading(false);
629
+ } catch (err) {
630
+ if (isInsufficientCapacityReserveError(err)) {
631
+ try {
632
+ const merged = await reloadAvailabilitiesAfterReserveConflict();
633
+ const outbound = findMergedAvailabilityForSelection(merged, selectedAvailability);
634
+ const outboundVacancies = outbound?.vacancies ?? null;
635
+ const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
636
+ setError(
637
+ describeStandardTourCapacityConflictMessage({
638
+ partySize: totalQuantity,
639
+ outboundVacancies,
640
+ returnVacancies,
641
+ hasReturnSelection: !!selectedReturnOption,
642
+ }),
643
+ );
644
+ reportReserveCapacityConflictClientContext({
645
+ flow: 'standard_tour',
646
+ productId: product.productId,
647
+ selectedDate: selectedDate || null,
648
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
649
+ outboundVacanciesAfterRefresh: outboundVacancies,
650
+ returnVacanciesAfterRefresh: returnVacancies,
651
+ partySizeOrPassengers: totalQuantity,
652
+ hasReturnSelection: !!selectedReturnOption,
653
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
654
+ reloadAvailabilitiesSucceeded: true,
655
+ });
656
+ } catch (reloadErr) {
657
+ setError(
658
+ describeStandardTourCapacityConflictMessage({
659
+ partySize: totalQuantity,
660
+ outboundVacancies: null,
661
+ returnVacancies: null,
662
+ hasReturnSelection: !!selectedReturnOption,
663
+ }),
664
+ );
665
+ reportReserveCapacityConflictClientContext({
666
+ flow: 'standard_tour',
667
+ productId: product.productId,
668
+ selectedDate: selectedDate || null,
669
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
670
+ outboundVacanciesAfterRefresh: null,
671
+ returnVacanciesAfterRefresh: null,
672
+ partySizeOrPassengers: totalQuantity,
673
+ hasReturnSelection: !!selectedReturnOption,
674
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
675
+ reloadAvailabilitiesSucceeded: false,
676
+ reloadErrorMessage:
677
+ reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
678
+ });
679
+ }
680
+ setLoading(false);
681
+ return;
682
+ }
683
+ setError(err instanceof Error ? err.message : 'Something went wrong');
684
+ setLoading(false);
685
+ }
686
+ };
687
+
688
+ const handleConfirmWithoutPayment = async () => {
689
+ if (!adminChoiceData) return;
690
+ setLoading(true);
691
+ setError('');
692
+ try {
693
+ if (onChangeBooking) {
694
+ if (!selectedAvailability) {
695
+ setError('No availability selected');
696
+ setLoading(false);
697
+ return;
698
+ }
699
+ const bookingItems = bookingItemsFromQuantities(quantities);
700
+ const availabilityProductOptionId =
701
+ adminChoiceData.availabilityProductOptionId ||
702
+ getAvailabilityOptionId(selectedAvailability);
703
+ if (!availabilityProductOptionId) {
704
+ setError('No product option selected');
705
+ setLoading(false);
706
+ return;
707
+ }
708
+ const providerPayload = buildProviderChangePayload(availabilityProductOptionId, bookingItems);
709
+ if (!providerPayload) {
710
+ setError('No availability selected');
711
+ setLoading(false);
712
+ return;
713
+ }
714
+ await onChangeBooking(providerPayload);
715
+ const bookRef = initialValues?.bookingReference?.trim() || adminChoiceData.reservationReference;
716
+ setShowAdminPaymentChoice(false);
717
+ setAdminChoiceData(null);
718
+ onSuccess?.({ reservationReference: bookRef });
719
+ finishManageFlow(bookRef);
720
+ setLoading(false);
721
+ return;
722
+ }
723
+
724
+ if (isChangeBookingContext) {
725
+ setError(
726
+ 'Confirming a booking change without payment requires your dashboard to pass the provider change handler (onChangeBooking). Use Pay now to complete this change, or wire onChangeBooking in the embed.',
727
+ );
728
+ setLoading(false);
729
+ return;
730
+ }
731
+
732
+ const result = await confirmAdminChangeBookingWithoutPayment({
733
+ adminChoiceData,
734
+ bookingSourceAttribution,
735
+ partnerPortalBooking,
736
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
737
+ productId: product.productId,
738
+ email,
739
+ firstName,
740
+ lastName,
741
+ currency,
742
+ travelerHotel: product.pickupLocations?.find((loc) => loc.id === pickupLocationId)?.name || undefined,
743
+ pickupLocationId: pickupLocationId || undefined,
744
+ termsAcceptedAt,
745
+ skipConfirmationCommunications,
746
+ disableAutoCommunications,
747
+ });
748
+ pendingReservationRef.current = null;
749
+ setShowAdminPaymentChoice(false);
750
+ setAdminChoiceData(null);
751
+ onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
752
+ if (onShowManage) {
753
+ onShowManage({ ref: result.displayReference, lastName: result.lastName });
754
+ } else {
755
+ const params = new URLSearchParams({
756
+ ref: result.displayReference,
757
+ lastName: result.lastName,
758
+ booking_complete: '1',
759
+ });
760
+ window.location.href = `/manage-booking?${params.toString()}`;
761
+ }
762
+ } catch (err) {
763
+ setError(err instanceof Error ? err.message : 'Failed to confirm booking');
764
+ } finally {
765
+ setLoading(false);
766
+ }
767
+ };
768
+
769
+ const handlePayNow = () => {
770
+ if (!adminChoiceData) return;
771
+ setShowAdminPaymentChoice(false);
772
+ setCheckoutClientSecret(adminChoiceData.clientSecret);
773
+ setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
774
+ setShowCheckoutModal(true);
775
+ setAdminChoiceData(null);
776
+ };
777
+
778
+ const handlePaymentSubmitStart = useCallback(() => {
779
+ paymentSubmitInFlightRef.current = true;
780
+ }, []);
781
+
782
+ const handlePaymentSubmitError = useCallback(() => {
783
+ paymentSubmitInFlightRef.current = false;
784
+ }, []);
785
+
786
+ const handleAdminPaymentChoiceCancel = useCallback(() => {
787
+ setShowAdminPaymentChoice(false);
788
+ setAdminChoiceData(null);
789
+ setError('');
790
+ }, [setError]);
791
+
792
+ return {
793
+ showCheckoutModal,
794
+ checkoutClientSecret,
795
+ checkoutModalData,
796
+ showAdminPaymentChoice,
797
+ adminChoiceData,
798
+ cancelPendingReservation,
799
+ handleCheckout,
800
+ handleConfirmWithoutPayment,
801
+ handlePayNow,
802
+ handlePaymentSubmitStart,
803
+ handlePaymentSubmitError,
804
+ handleAdminPaymentChoiceCancel,
805
+ };
806
+ }