@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,438 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import type {
3
+ AddOn,
4
+ Availability,
5
+ ItineraryDisplayStep,
6
+ PrecomputedPricesByCategory,
7
+ PricingConfig,
8
+ Product,
9
+ ReturnOption,
10
+ } from '../../lib/booking-api';
11
+ import {
12
+ computeOrderSummary,
13
+ computePriceBreakdown,
14
+ type PriceBreakdown as PriceBreakdownData,
15
+ } from '../../lib/booking/pricing';
16
+ import type {
17
+ BookingFlowPriceSummaryAugmentation,
18
+ NewBookingFlowProps,
19
+ } from './booking-flow-types';
20
+ import type { Currency } from './CurrencySwitcher';
21
+ import type { PriceSummaryLine } from './PriceSummary';
22
+ import {
23
+ buildPricingFromAvailability,
24
+ getAvailabilityOptionId,
25
+ } from './standard-booking-availability';
26
+ import { useStandardBookingPromoDiscount } from './useStandardBookingPromoDiscount';
27
+
28
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
29
+ type AppliedAdjustment = {
30
+ type: string;
31
+ id: string;
32
+ name: string;
33
+ changeByCurrency?: Record<string, number>;
34
+ };
35
+ type ForcedCancellationPolicy = {
36
+ id: string;
37
+ label: string;
38
+ refundTiers?: Array<{ hoursBefore: number; refundPercent: number }>;
39
+ changeWindowHoursBefore?: number | null;
40
+ } | null;
41
+
42
+ interface UseStandardBookingPriceSummaryParams {
43
+ selectedAvailability: Availability | null;
44
+ selectedReturnOption: ReturnOption | null;
45
+ activeOptions: Product['options'];
46
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined;
47
+ currency: Currency;
48
+ pricingConfig: PricingConfig | null;
49
+ isSimplifiedPricingView: boolean;
50
+ quantities: Record<string, number>;
51
+ cancellationPolicyId: string | null;
52
+ forcedCancellationPolicy: ForcedCancellationPolicy;
53
+ addOnSelections: AddOnSelection[];
54
+ addOns: AddOn[];
55
+ t: (key: string) => string;
56
+ appliedPromoCode: string | null;
57
+ productCompanyId?: string | null;
58
+ envCompanyId?: string | null;
59
+ productId: string;
60
+ selectedDate: string;
61
+ computeItineraryDisplay: () => ItineraryDisplayStep[] | null;
62
+ augmentPriceSummary?: NewBookingFlowProps['augmentPriceSummary'];
63
+ }
64
+
65
+ export function useStandardBookingPriceSummary({
66
+ selectedAvailability,
67
+ selectedReturnOption,
68
+ activeOptions,
69
+ precomputedPricesByOption,
70
+ currency,
71
+ pricingConfig,
72
+ isSimplifiedPricingView,
73
+ quantities,
74
+ cancellationPolicyId,
75
+ forcedCancellationPolicy,
76
+ addOnSelections,
77
+ addOns,
78
+ t,
79
+ appliedPromoCode,
80
+ productCompanyId,
81
+ envCompanyId,
82
+ productId,
83
+ selectedDate,
84
+ computeItineraryDisplay,
85
+ augmentPriceSummary,
86
+ }: UseStandardBookingPriceSummaryParams) {
87
+ const hasFees = useMemo(
88
+ () =>
89
+ Boolean(
90
+ pricingConfig?.fees &&
91
+ Object.keys(pricingConfig.fees).length > 0 &&
92
+ Object.values(pricingConfig.fees).some((value) => (value?.feePerPerson ?? 0) > 0),
93
+ ),
94
+ [pricingConfig?.fees],
95
+ );
96
+
97
+ const returnOptionsWithFloor = useMemo(
98
+ () => selectedAvailability?.returnOptions ?? [],
99
+ [selectedAvailability?.returnOptions],
100
+ );
101
+
102
+ const selectedReturnOptionWithFloor = selectedReturnOption;
103
+ const returnOptionForOrderSummary = selectedReturnOptionWithFloor;
104
+
105
+ const effectiveSelectedPickupVacancies = useMemo(() => {
106
+ if (!selectedAvailability) return 0;
107
+ return Math.max(0, selectedAvailability.vacancies ?? 0);
108
+ }, [selectedAvailability]);
109
+
110
+ const effectiveSelectedReturnVacancies = useMemo(() => {
111
+ if (!selectedReturnOption) return null;
112
+ return Math.max(0, selectedReturnOption.vacancies ?? 0);
113
+ }, [selectedReturnOption]);
114
+
115
+ const pricing = useMemo(
116
+ () =>
117
+ buildPricingFromAvailability(
118
+ selectedAvailability,
119
+ activeOptions,
120
+ precomputedPricesByOption,
121
+ currency,
122
+ pricingConfig,
123
+ hasFees,
124
+ isSimplifiedPricingView,
125
+ ),
126
+ [
127
+ selectedAvailability,
128
+ activeOptions,
129
+ precomputedPricesByOption,
130
+ currency,
131
+ pricingConfig,
132
+ hasFees,
133
+ isSimplifiedPricingView,
134
+ ],
135
+ );
136
+
137
+ const getPriceBreakdown = useCallback(
138
+ (
139
+ category: string,
140
+ priceCAD: number,
141
+ baseInDisplayCurrency: number | undefined,
142
+ appliedAdjustments: AppliedAdjustment[] = [],
143
+ ): PriceBreakdownData | null => {
144
+ if (!pricingConfig) return null;
145
+ const selectedOption = activeOptions.find(
146
+ (option) =>
147
+ option.optionId ===
148
+ (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''),
149
+ );
150
+ const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
151
+ return computePriceBreakdown(
152
+ pricingConfig,
153
+ currency,
154
+ priceCAD,
155
+ basePriceCAD,
156
+ hasFees,
157
+ appliedAdjustments,
158
+ undefined,
159
+ baseInDisplayCurrency,
160
+ isSimplifiedPricingView,
161
+ );
162
+ },
163
+ [pricingConfig, activeOptions, selectedAvailability, currency, hasFees, isSimplifiedPricingView],
164
+ );
165
+
166
+ const orderSummary = useMemo(
167
+ () =>
168
+ computeOrderSummary(
169
+ quantities,
170
+ pricing,
171
+ returnOptionForOrderSummary,
172
+ pricingConfig ?? null,
173
+ currency,
174
+ hasFees,
175
+ cancellationPolicyId,
176
+ ),
177
+ [
178
+ quantities,
179
+ pricing,
180
+ returnOptionForOrderSummary,
181
+ pricingConfig,
182
+ currency,
183
+ hasFees,
184
+ cancellationPolicyId,
185
+ ],
186
+ );
187
+
188
+ const {
189
+ totalQuantity,
190
+ subtotal,
191
+ tax,
192
+ total: totalFromSummary,
193
+ feeLineItems,
194
+ returnPriceAdjustment,
195
+ cancellationPolicyFee,
196
+ isTaxIncludedInPrice,
197
+ ticketLineItems,
198
+ } = orderSummary;
199
+
200
+ const effectivePartySizeCap = useMemo(() => {
201
+ if (!selectedAvailability) return 0;
202
+ const outbound = Math.max(0, selectedAvailability.vacancies ?? 0);
203
+ if (selectedReturnOption == null) return outbound;
204
+ const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0);
205
+ return Math.min(outbound, returnCap);
206
+ }, [selectedAvailability, selectedReturnOption]);
207
+
208
+ const selectedCancellationPolicy = useMemo(
209
+ () => pricingConfig?.cancellationPolicies?.find((policy) => policy.id === cancellationPolicyId),
210
+ [pricingConfig?.cancellationPolicies, cancellationPolicyId],
211
+ );
212
+
213
+ const effectiveCancellationPolicyLabel =
214
+ selectedCancellationPolicy?.label ??
215
+ forcedCancellationPolicy?.label ??
216
+ (t('booking.flexibleCancellation') || 'Flexible cancellation');
217
+
218
+ const addOnTotal = useMemo(() => {
219
+ let sum = 0;
220
+ for (const selection of addOnSelections) {
221
+ const addOn = addOns.find((candidate) => candidate.addOnId === selection.addOnId);
222
+ if (!addOn) continue;
223
+ const basePrice = addOn.price ?? 0;
224
+ const hasVariant =
225
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
226
+ selection.variantId;
227
+ const variantAdjustment = hasVariant
228
+ ? (addOn.variants?.find((variant) => variant.id === selection.variantId)?.priceAdjustment ?? 0)
229
+ : 0;
230
+ sum += (basePrice + variantAdjustment) * (selection.quantity ?? 1);
231
+ }
232
+ return sum;
233
+ }, [addOnSelections, addOns]);
234
+
235
+ const checkoutReturnLineAmount = returnPriceAdjustment;
236
+ const effectiveSubtotalBeforeAddOns = subtotal;
237
+ const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
238
+
239
+ const feeLineItemsWithAddOns = useMemo(() => {
240
+ const addOnLines = addOnSelections
241
+ .map((selection) => {
242
+ const addOn = addOns.find((candidate) => candidate.addOnId === selection.addOnId);
243
+ if (!addOn) return null;
244
+ const base = addOn.price ?? 0;
245
+ const hasVariant =
246
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
247
+ selection.variantId;
248
+ const adjustment = hasVariant
249
+ ? (addOn.variants?.find((variant) => variant.id === selection.variantId)?.priceAdjustment ?? 0)
250
+ : 0;
251
+ const quantity = selection.quantity ?? 1;
252
+ const amount = (base + adjustment) * quantity;
253
+ const variantLabel = hasVariant
254
+ ? addOn.variants?.find((variant) => variant.id === selection.variantId)?.label
255
+ : null;
256
+ const name = variantLabel
257
+ ? `${addOn.name} (${variantLabel})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
258
+ : addOn.name;
259
+ return { name, totalAmount: amount, description: addOn.description ?? undefined };
260
+ })
261
+ .filter((line): line is NonNullable<typeof line> => line != null);
262
+ return [...feeLineItems, ...addOnLines];
263
+ }, [feeLineItems, addOnSelections, addOns]);
264
+
265
+ const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
266
+ if (!selectedAvailability) return [];
267
+ const returnLineAmount = checkoutReturnLineAmount;
268
+ const showReturnLine =
269
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
270
+ return [
271
+ ...ticketLineItems.map((line): PriceSummaryLine => {
272
+ const rate = pricing.find((candidate) => candidate.category === line.category);
273
+ const breakdown = getPriceBreakdown(
274
+ line.category,
275
+ rate?.priceCAD ?? 0,
276
+ rate?.baseInDisplayCurrency,
277
+ rate?.appliedAdjustments ?? [],
278
+ );
279
+ return {
280
+ kind: 'ticket',
281
+ category: line.category,
282
+ qty: line.qty,
283
+ itemTotal: line.itemTotal,
284
+ breakdown,
285
+ };
286
+ }),
287
+ ...(showReturnLine
288
+ ? [
289
+ {
290
+ kind: 'line' as const,
291
+ label: `${t('booking.returnOption')} (${totalQuantity} ${
292
+ totalQuantity === 1 ? 'person' : 'people'
293
+ })`,
294
+ amount: returnLineAmount,
295
+ type: 'return',
296
+ },
297
+ ]
298
+ : []),
299
+ ...(cancellationPolicyFee > 0 && (selectedCancellationPolicy || forcedCancellationPolicy)
300
+ ? [
301
+ {
302
+ kind: 'line' as const,
303
+ label: effectiveCancellationPolicyLabel,
304
+ amount: cancellationPolicyFee,
305
+ type: 'cancellation',
306
+ },
307
+ ]
308
+ : []),
309
+ ...feeLineItemsWithAddOns.map((fee) => {
310
+ const normalizedName = fee.name.toLowerCase();
311
+ const isMoraineLakeRoadAccessFee =
312
+ normalizedName.includes('moraine') &&
313
+ (normalizedName.includes('access') ||
314
+ normalizedName.includes('road') ||
315
+ normalizedName.includes('license'));
316
+ return {
317
+ kind: 'line' as const,
318
+ label: feeLineItems.some((candidate) => candidate.name === fee.name)
319
+ ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
320
+ : fee.name,
321
+ amount: fee.totalAmount,
322
+ type: 'fee',
323
+ tooltip: isMoraineLakeRoadAccessFee
324
+ ? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
325
+ : undefined,
326
+ };
327
+ }),
328
+ ];
329
+ }, [
330
+ selectedAvailability,
331
+ selectedReturnOption,
332
+ checkoutReturnLineAmount,
333
+ ticketLineItems,
334
+ pricing,
335
+ getPriceBreakdown,
336
+ t,
337
+ totalQuantity,
338
+ cancellationPolicyFee,
339
+ selectedCancellationPolicy,
340
+ forcedCancellationPolicy,
341
+ effectiveCancellationPolicyLabel,
342
+ feeLineItemsWithAddOns,
343
+ feeLineItems,
344
+ ]);
345
+
346
+ const {
347
+ promoDiscountAmount,
348
+ isGiftCard,
349
+ isVoucher,
350
+ effectivePromoDiscountAmount,
351
+ effectiveTax,
352
+ totalPrice,
353
+ } = useStandardBookingPromoDiscount({
354
+ appliedPromoCode,
355
+ selectedAvailability,
356
+ totalQuantity,
357
+ productCompanyId,
358
+ envCompanyId,
359
+ productId,
360
+ currency,
361
+ quantities,
362
+ ticketLineItems,
363
+ feeLineItems,
364
+ effectiveSubtotal,
365
+ isTaxIncludedInPrice,
366
+ pricingConfig,
367
+ });
368
+
369
+ const priceSummaryAugmentation = useMemo<BookingFlowPriceSummaryAugmentation | null>(
370
+ () =>
371
+ augmentPriceSummary
372
+ ? augmentPriceSummary({
373
+ selectedDate: selectedDate || null,
374
+ selectedAvailability,
375
+ itineraryItems: computeItineraryDisplay(),
376
+ priceSummaryLines: checkoutPriceSummaryLines,
377
+ subtotal: effectiveSubtotal,
378
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
379
+ total: totalPrice,
380
+ currency,
381
+ })
382
+ : null,
383
+ [
384
+ augmentPriceSummary,
385
+ selectedDate,
386
+ selectedAvailability,
387
+ computeItineraryDisplay,
388
+ checkoutPriceSummaryLines,
389
+ effectiveSubtotal,
390
+ effectivePromoDiscountAmount,
391
+ effectiveTax,
392
+ tax,
393
+ totalPrice,
394
+ currency,
395
+ ],
396
+ );
397
+
398
+ const displayCheckoutPriceSummaryLines =
399
+ priceSummaryAugmentation?.lines ?? checkoutPriceSummaryLines;
400
+ const displaySubtotal =
401
+ effectiveSubtotal + (priceSummaryAugmentation?.subtotalAdjustment ?? 0);
402
+ const displayTotalPrice =
403
+ totalPrice + (priceSummaryAugmentation?.totalAdjustment ?? 0);
404
+
405
+ return {
406
+ returnOptionsWithFloor,
407
+ selectedReturnOptionWithFloor,
408
+ effectiveSelectedPickupVacancies,
409
+ effectiveSelectedReturnVacancies,
410
+ pricing,
411
+ getPriceBreakdown,
412
+ totalQuantity,
413
+ subtotal,
414
+ tax,
415
+ totalFromSummary,
416
+ feeLineItems,
417
+ cancellationPolicyFee,
418
+ isTaxIncludedInPrice,
419
+ ticketLineItems,
420
+ effectivePartySizeCap,
421
+ effectiveCancellationPolicyLabel,
422
+ addOnTotal,
423
+ checkoutReturnLineAmount,
424
+ effectiveSubtotal,
425
+ feeLineItemsWithAddOns,
426
+ checkoutPriceSummaryLines,
427
+ promoDiscountAmount,
428
+ isGiftCard,
429
+ isVoucher,
430
+ effectivePromoDiscountAmount,
431
+ effectiveTax,
432
+ totalPrice,
433
+ priceSummaryAugmentation,
434
+ displayCheckoutPriceSummaryLines,
435
+ displaySubtotal,
436
+ displayTotalPrice,
437
+ };
438
+ }
@@ -0,0 +1,258 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type Dispatch,
8
+ type SetStateAction,
9
+ } from 'react';
10
+ import {
11
+ validatePromoCode,
12
+ type Availability,
13
+ type RefundTierOption,
14
+ } from '../../lib/booking-api';
15
+ import { getAvailabilityOptionId } from './standard-booking-availability';
16
+
17
+ type TranslationFn = (key: string) => string;
18
+
19
+ export type StandardBookingForcedCancellationPolicy = {
20
+ id: string;
21
+ label: string;
22
+ refundTiers?: RefundTierOption[];
23
+ changeWindowHoursBefore?: number | null;
24
+ } | null;
25
+
26
+ export interface UseStandardBookingPromoControllerParams {
27
+ autoAppliedPromoCode?: string | null;
28
+ initialPromoCode?: string | null;
29
+ promoCodeInput: string;
30
+ setPromoCodeInput: Dispatch<SetStateAction<string>>;
31
+ appliedPromoCode: string | null;
32
+ setAppliedPromoCode: Dispatch<SetStateAction<string | null>>;
33
+ selectedAvailability: Availability | null;
34
+ totalQuantity: number;
35
+ productCompanyId?: string | null;
36
+ productId: string;
37
+ clearFetchedAvailabilityRanges: () => void;
38
+ setCancellationPolicyId: Dispatch<SetStateAction<string | null>>;
39
+ t: TranslationFn;
40
+ }
41
+
42
+ export function useStandardBookingPromoController({
43
+ autoAppliedPromoCode,
44
+ initialPromoCode,
45
+ promoCodeInput,
46
+ setPromoCodeInput,
47
+ appliedPromoCode,
48
+ setAppliedPromoCode,
49
+ selectedAvailability,
50
+ totalQuantity,
51
+ productCompanyId,
52
+ productId,
53
+ clearFetchedAvailabilityRanges,
54
+ setCancellationPolicyId,
55
+ t,
56
+ }: UseStandardBookingPromoControllerParams) {
57
+ const [promoCodeError, setPromoCodeError] = useState('');
58
+ const [promoCodeValidating, setPromoCodeValidating] = useState(false);
59
+ const [forcedCancellationPolicy, setForcedCancellationPolicy] =
60
+ useState<StandardBookingForcedCancellationPolicy>(null);
61
+ const cancellationPolicyRef = useRef<HTMLDivElement>(null);
62
+ const hasAppliedInitialPromoRef = useRef(false);
63
+ const promoAppliedSelectionKeyRef = useRef<string | null>(null);
64
+ const promoValidateInFlightRef = useRef(false);
65
+
66
+ useEffect(() => {
67
+ if (hasAppliedInitialPromoRef.current) return;
68
+ const normalizedPromo = initialPromoCode?.trim().toUpperCase();
69
+ if (!normalizedPromo) return;
70
+ hasAppliedInitialPromoRef.current = true;
71
+ setPromoCodeInput((current) => current || normalizedPromo);
72
+ setAppliedPromoCode((current) => current ?? normalizedPromo);
73
+ }, [initialPromoCode, setAppliedPromoCode, setPromoCodeInput]);
74
+
75
+ useEffect(() => {
76
+ if (!autoAppliedPromoCode) return;
77
+ const normalizedPromo = autoAppliedPromoCode.trim().toUpperCase();
78
+ if (!normalizedPromo) return;
79
+ setPromoCodeInput((current) => current || normalizedPromo);
80
+ }, [autoAppliedPromoCode, setPromoCodeInput]);
81
+
82
+ const hasOngoingDiscount = useMemo(
83
+ () =>
84
+ selectedAvailability?.rates?.some((rate) =>
85
+ (rate.appliedAdjustments ?? rate.applied_adjustments ?? []).some(
86
+ (adjustment) => (adjustment.type ?? '').toLowerCase() === 'deal',
87
+ ),
88
+ ) ?? false,
89
+ [selectedAvailability],
90
+ );
91
+
92
+ const selectedAvailabilityKey = useMemo(
93
+ () =>
94
+ `${selectedAvailability?.dateTime ?? ''}::${
95
+ selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''
96
+ }`,
97
+ [selectedAvailability],
98
+ );
99
+
100
+ const getPromoErrorMessage = useCallback(
101
+ (error?: string) =>
102
+ error === 'Promo codes cannot be stacked with deals'
103
+ ? (t('booking.promoCodesCannotStackWithDiscounts') || error)
104
+ : (error || t('booking.invalidPromoCode') || 'Invalid or expired promo code'),
105
+ [t],
106
+ );
107
+
108
+ const handlePromoInputChange = useCallback(
109
+ (value: string) => {
110
+ setPromoCodeInput(value);
111
+ setPromoCodeError('');
112
+ },
113
+ [setPromoCodeInput],
114
+ );
115
+
116
+ const handleRemovePromo = useCallback(() => {
117
+ promoAppliedSelectionKeyRef.current = null;
118
+ setAppliedPromoCode(null);
119
+ setPromoCodeInput('');
120
+ setPromoCodeError('');
121
+ setForcedCancellationPolicy(null);
122
+ setCancellationPolicyId(null);
123
+ clearFetchedAvailabilityRanges();
124
+ }, [
125
+ clearFetchedAvailabilityRanges,
126
+ setAppliedPromoCode,
127
+ setCancellationPolicyId,
128
+ setPromoCodeInput,
129
+ ]);
130
+
131
+ const handleApplyPromo = useCallback(async () => {
132
+ const code = promoCodeInput.trim().toUpperCase();
133
+ if (!code) return;
134
+ if (!selectedAvailability || totalQuantity <= 0) return;
135
+ if (appliedPromoCode === code) return;
136
+ if (!productCompanyId) return;
137
+ if (promoValidateInFlightRef.current) return;
138
+
139
+ promoValidateInFlightRef.current = true;
140
+ setPromoCodeError('');
141
+ setPromoCodeValidating(true);
142
+ try {
143
+ const result = await validatePromoCode(
144
+ code,
145
+ productCompanyId,
146
+ productId,
147
+ hasOngoingDiscount,
148
+ selectedAvailability.dateTime,
149
+ );
150
+ if (result.valid) {
151
+ promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
152
+ setAppliedPromoCode(code);
153
+ clearFetchedAvailabilityRanges();
154
+ if (result.forcedCancellationPolicyId && result.forcedCancellationPolicyLabel) {
155
+ setForcedCancellationPolicy({
156
+ id: result.forcedCancellationPolicyId,
157
+ label: result.forcedCancellationPolicyLabel,
158
+ refundTiers: result.forcedCancellationPolicyRefundTiers,
159
+ changeWindowHoursBefore: result.forcedChangeWindowHoursBefore ?? undefined,
160
+ });
161
+ setCancellationPolicyId(result.forcedCancellationPolicyId);
162
+ setTimeout(() => {
163
+ cancellationPolicyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
164
+ }, 100);
165
+ } else {
166
+ setForcedCancellationPolicy(null);
167
+ }
168
+ } else {
169
+ setPromoCodeError(getPromoErrorMessage(result.error));
170
+ }
171
+ } catch (err) {
172
+ setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
173
+ } finally {
174
+ promoValidateInFlightRef.current = false;
175
+ setPromoCodeValidating(false);
176
+ }
177
+ }, [
178
+ promoCodeInput,
179
+ selectedAvailability,
180
+ totalQuantity,
181
+ appliedPromoCode,
182
+ productCompanyId,
183
+ productId,
184
+ hasOngoingDiscount,
185
+ selectedAvailabilityKey,
186
+ setAppliedPromoCode,
187
+ clearFetchedAvailabilityRanges,
188
+ setCancellationPolicyId,
189
+ getPromoErrorMessage,
190
+ ]);
191
+
192
+ useEffect(() => {
193
+ if (!appliedPromoCode || !selectedAvailability) return;
194
+ if (promoAppliedSelectionKeyRef.current === selectedAvailabilityKey) return;
195
+
196
+ let cancelled = false;
197
+ validatePromoCode(
198
+ appliedPromoCode,
199
+ productCompanyId ?? '',
200
+ productId,
201
+ hasOngoingDiscount,
202
+ selectedAvailability.dateTime,
203
+ ).then((result) => {
204
+ if (cancelled) return;
205
+ if (!result.valid) {
206
+ promoAppliedSelectionKeyRef.current = null;
207
+ setAppliedPromoCode(null);
208
+ setPromoCodeInput(appliedPromoCode);
209
+ setPromoCodeError(getPromoErrorMessage(result.error));
210
+ setForcedCancellationPolicy(null);
211
+ setCancellationPolicyId(null);
212
+ clearFetchedAvailabilityRanges();
213
+ }
214
+ });
215
+
216
+ return () => {
217
+ cancelled = true;
218
+ };
219
+ }, [
220
+ hasOngoingDiscount,
221
+ appliedPromoCode,
222
+ productCompanyId,
223
+ productId,
224
+ selectedAvailabilityKey,
225
+ selectedAvailability,
226
+ setAppliedPromoCode,
227
+ setPromoCodeInput,
228
+ setCancellationPolicyId,
229
+ clearFetchedAvailabilityRanges,
230
+ getPromoErrorMessage,
231
+ ]);
232
+
233
+ const handleApplyPromoRef = useRef(handleApplyPromo);
234
+ handleApplyPromoRef.current = handleApplyPromo;
235
+
236
+ useEffect(() => {
237
+ const trimmed = promoCodeInput.trim().toUpperCase();
238
+ if (!trimmed) return;
239
+ if (!selectedAvailability || totalQuantity <= 0) return;
240
+ if (appliedPromoCode === trimmed) return;
241
+
242
+ const timer = setTimeout(() => {
243
+ handleApplyPromoRef.current();
244
+ }, 600);
245
+
246
+ return () => clearTimeout(timer);
247
+ }, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
248
+
249
+ return {
250
+ promoCodeError,
251
+ promoCodeValidating,
252
+ forcedCancellationPolicy,
253
+ cancellationPolicyRef,
254
+ handlePromoInputChange,
255
+ handleApplyPromo,
256
+ handleRemovePromo,
257
+ };
258
+ }