@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,178 @@
1
+ import { useMemo } from 'react';
2
+ import type { AdminFeAuthoritativeReceipt } from '../../lib/booking-api';
3
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
4
+ import { formatCurrencyAmount } from '../../lib/currency';
5
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
6
+ import type { Currency } from './CurrencySwitcher';
7
+ import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
8
+
9
+ type DisplayedChangeAmounts = {
10
+ subtotal: number;
11
+ tax: number;
12
+ total: number;
13
+ };
14
+
15
+ type EditableSummaryDebugRow = {
16
+ label: string;
17
+ baseline: number;
18
+ edited: number;
19
+ delta: number;
20
+ };
21
+
22
+ export interface UseAdminChangePricingDebugPanelParams {
23
+ isAdmin: boolean;
24
+ hasSelectedAvailability: boolean;
25
+ totalQuantity: number;
26
+ useAdminFeAuthoritativeQuote: boolean;
27
+ currency: Currency;
28
+ locale: string;
29
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
30
+ isCustomerSelfServeChange: boolean;
31
+ latestChangeQuote: AdminChangeLatestQuote | null;
32
+ changeQuoteFetchError: string | null;
33
+ displayLayerUsesExternalPricing: boolean;
34
+ selfServePricingConfirmed: boolean;
35
+ changeQuoteLoading: boolean;
36
+ effectiveSubtotal: number;
37
+ adminCustomAdjustmentTotal: number;
38
+ editableSummaryPreSubtotalDebugRows: EditableSummaryDebugRow[];
39
+ effectiveSubtotalForCheckout: number;
40
+ effectiveTax: number;
41
+ effectivePromoDiscountAmount: number;
42
+ totalPrice: number;
43
+ changeFlowNewBookingTotal: number;
44
+ displayedChangeAmountsRaw: DisplayedChangeAmounts;
45
+ editableSummaryPreSubtotalDelta: number;
46
+ displayChangeFlowProposedTotal: number;
47
+ displayChangeFlowProposedTotalWithEditableLines: number;
48
+ adminFeAuthoritativeReceipt: AdminFeAuthoritativeReceipt;
49
+ changeFlowClientEstimateDue: number;
50
+ changeFlowAmountDue: number;
51
+ }
52
+
53
+ export function useAdminChangePricingDebugPanel({
54
+ isAdmin,
55
+ hasSelectedAvailability,
56
+ totalQuantity,
57
+ useAdminFeAuthoritativeQuote,
58
+ currency,
59
+ locale,
60
+ originalReceipt,
61
+ isCustomerSelfServeChange,
62
+ latestChangeQuote,
63
+ changeQuoteFetchError,
64
+ displayLayerUsesExternalPricing,
65
+ selfServePricingConfirmed,
66
+ changeQuoteLoading,
67
+ effectiveSubtotal,
68
+ adminCustomAdjustmentTotal,
69
+ editableSummaryPreSubtotalDebugRows,
70
+ effectiveSubtotalForCheckout,
71
+ effectiveTax,
72
+ effectivePromoDiscountAmount,
73
+ totalPrice,
74
+ changeFlowNewBookingTotal,
75
+ displayedChangeAmountsRaw,
76
+ editableSummaryPreSubtotalDelta,
77
+ displayChangeFlowProposedTotal,
78
+ displayChangeFlowProposedTotalWithEditableLines,
79
+ adminFeAuthoritativeReceipt,
80
+ changeFlowClientEstimateDue,
81
+ changeFlowAmountDue,
82
+ }: UseAdminChangePricingDebugPanelParams) {
83
+ return useMemo(() => {
84
+ if (!isAdmin || !hasSelectedAvailability || totalQuantity <= 0) return null;
85
+ const fmt = (n: number) => formatCurrencyAmount(roundMoney(n), currency, locale as 'en' | 'fr');
86
+ const path = (() => {
87
+ if (!originalReceipt) return 'totalPrice (no original receipt)';
88
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
89
+ if (
90
+ latestChangeQuote.quotePreviousTotalCents != null &&
91
+ latestChangeQuote.quoteNewTotalCents != null
92
+ ) {
93
+ return 'POST /change/quote: (quoteNewTotalCents - quotePreviousTotalCents) / 100';
94
+ }
95
+ if (latestChangeQuote.serverDisplay != null) {
96
+ return 'POST /change/quote: serverDisplay.total - originalReceipt.total';
97
+ }
98
+ return 'POST /change/quote: priceDiff (API balance delta)';
99
+ }
100
+ return 'FE: displayChangeFlowProposedTotal - originalReceipt.total (signed; used when quote not driving amount-due)';
101
+ })();
102
+ const quoteTotalPreview =
103
+ latestChangeQuote?.serverDisplay != null ? fmt(latestChangeQuote.serverDisplay.total) : '-';
104
+
105
+ return (
106
+ <details className="mt-3 rounded-md border border-amber-200/80 bg-amber-50/50 p-2 text-left text-xs text-amber-950">
107
+ <summary className="cursor-pointer select-none font-medium text-stone-800">
108
+ Price calculation (admin debug)
109
+ </summary>
110
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
111
+ {[
112
+ `Amount-due path: ${path}`,
113
+ `adminFeAuthoritativeQuote: ${String(useAdminFeAuthoritativeQuote)}`,
114
+ `displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
115
+ `selfServePricingConfirmed: ${String(selfServePricingConfirmed)} | changeQuoteLoading: ${String(changeQuoteLoading)}`,
116
+ `Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
117
+ `Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
118
+ `Pre-subtotal line overrides:`,
119
+ ...editableSummaryPreSubtotalDebugRows.map(
120
+ (row, idx) =>
121
+ ` ${idx + 1}. ${row.label} | baseline ${fmt(row.baseline)} -> edited ${fmt(row.edited)} (delta ${fmt(row.delta)})`
122
+ ),
123
+ `Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
124
+ `effectiveTax | promo discount: ${fmt(effectiveTax)} | ${fmt(effectivePromoDiscountAmount)}`,
125
+ `totalPrice (subtotal + tax - promo): ${fmt(totalPrice)}`,
126
+ `changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
127
+ `Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
128
+ `Pre-subtotal editable lines delta: ${fmt(editableSummaryPreSubtotalDelta)}`,
129
+ `displayChangeFlowProposedTotal (base): ${fmt(displayChangeFlowProposedTotal)}`,
130
+ `displayChangeFlowProposedTotal (with editable lines): ${fmt(displayChangeFlowProposedTotalWithEditableLines)}`,
131
+ `feReceipt sent subtotal/tax/total: ${fmt(adminFeAuthoritativeReceipt.subtotal)} / ${fmt(adminFeAuthoritativeReceipt.tax)} / ${fmt(adminFeAuthoritativeReceipt.total)}`,
132
+ `feReceipt sent lineItems count: ${String(adminFeAuthoritativeReceipt.lineItems.length)}`,
133
+ originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: -',
134
+ `Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
135
+ `Quote serverDisplay subtotal/tax/total: ${
136
+ latestChangeQuote?.serverDisplay
137
+ ? `${fmt(latestChangeQuote.serverDisplay.subtotal)} / ${fmt(latestChangeQuote.serverDisplay.tax)} / ${fmt(latestChangeQuote.serverDisplay.total)}`
138
+ : '-'
139
+ }`,
140
+ `changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
141
+ `changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
142
+ ].join('\n')}
143
+ </pre>
144
+ </details>
145
+ );
146
+ }, [
147
+ isAdmin,
148
+ hasSelectedAvailability,
149
+ totalQuantity,
150
+ useAdminFeAuthoritativeQuote,
151
+ currency,
152
+ locale,
153
+ originalReceipt,
154
+ isCustomerSelfServeChange,
155
+ latestChangeQuote,
156
+ changeQuoteFetchError,
157
+ displayLayerUsesExternalPricing,
158
+ selfServePricingConfirmed,
159
+ changeQuoteLoading,
160
+ effectiveSubtotal,
161
+ adminCustomAdjustmentTotal,
162
+ editableSummaryPreSubtotalDebugRows,
163
+ effectiveSubtotalForCheckout,
164
+ effectiveTax,
165
+ effectivePromoDiscountAmount,
166
+ totalPrice,
167
+ changeFlowNewBookingTotal,
168
+ displayedChangeAmountsRaw.subtotal,
169
+ displayedChangeAmountsRaw.tax,
170
+ displayedChangeAmountsRaw.total,
171
+ editableSummaryPreSubtotalDelta,
172
+ displayChangeFlowProposedTotal,
173
+ displayChangeFlowProposedTotalWithEditableLines,
174
+ adminFeAuthoritativeReceipt,
175
+ changeFlowClientEstimateDue,
176
+ changeFlowAmountDue,
177
+ ]);
178
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
2
+ import type { AddOn, Availability, ReturnOption } from '../../lib/booking-api';
3
+ import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
4
+
5
+ interface ResetRef {
6
+ current: string | null;
7
+ }
8
+
9
+ export interface UseAdminChangeProductResetParams {
10
+ selectedChangeProductId: string;
11
+ setSelectedAvailability: Dispatch<SetStateAction<Availability | null>>;
12
+ setSelectedReturnOption: Dispatch<SetStateAction<ReturnOption | null>>;
13
+ setAddOns: Dispatch<SetStateAction<AddOn[]>>;
14
+ lastCompletedQuoteInputsKeyRef: ResetRef;
15
+ inFlightQuoteInputsKeyRef: ResetRef;
16
+ setLatestChangeQuote: Dispatch<SetStateAction<AdminChangeLatestQuote | null>>;
17
+ setChangeQuoteFetchError: Dispatch<SetStateAction<string | null>>;
18
+ setChangeQuoteLoading: Dispatch<SetStateAction<boolean>>;
19
+ setError: Dispatch<SetStateAction<string>>;
20
+ }
21
+
22
+ export function useAdminChangeProductReset({
23
+ selectedChangeProductId,
24
+ setSelectedAvailability,
25
+ setSelectedReturnOption,
26
+ setAddOns,
27
+ lastCompletedQuoteInputsKeyRef,
28
+ inFlightQuoteInputsKeyRef,
29
+ setLatestChangeQuote,
30
+ setChangeQuoteFetchError,
31
+ setChangeQuoteLoading,
32
+ setError,
33
+ }: UseAdminChangeProductResetParams) {
34
+ const previousSelectedChangeProductIdRef = useRef(selectedChangeProductId);
35
+
36
+ useEffect(() => {
37
+ if (previousSelectedChangeProductIdRef.current === selectedChangeProductId) return;
38
+ previousSelectedChangeProductIdRef.current = selectedChangeProductId;
39
+ setSelectedAvailability(null);
40
+ setSelectedReturnOption(null);
41
+ setAddOns([]);
42
+ lastCompletedQuoteInputsKeyRef.current = null;
43
+ inFlightQuoteInputsKeyRef.current = null;
44
+ setLatestChangeQuote(null);
45
+ setChangeQuoteFetchError(null);
46
+ setChangeQuoteLoading(false);
47
+ setError('');
48
+ }, [
49
+ selectedChangeProductId,
50
+ setSelectedAvailability,
51
+ setSelectedReturnOption,
52
+ setAddOns,
53
+ lastCompletedQuoteInputsKeyRef,
54
+ inFlightQuoteInputsKeyRef,
55
+ setLatestChangeQuote,
56
+ setChangeQuoteFetchError,
57
+ setChangeQuoteLoading,
58
+ setError,
59
+ ]);
60
+ }
@@ -0,0 +1,102 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ fetchProducts,
4
+ type Product,
5
+ } from '../../lib/booking-api';
6
+ import { isParentProductId } from '../../lib/booking/product-option-id';
7
+ import {
8
+ getAdminChangeEligibleProducts,
9
+ isPrivateShuttleProduct,
10
+ } from './change-booking-flow-helpers';
11
+
12
+ interface UseAdminChangeProductsParams {
13
+ initialProduct: Product;
14
+ changeProductOptions?: Product[];
15
+ initialValuesProductId?: string | null;
16
+ isAdmin: boolean;
17
+ companyId?: string | null;
18
+ }
19
+
20
+ export function useAdminChangeProducts({
21
+ initialProduct,
22
+ changeProductOptions,
23
+ initialValuesProductId,
24
+ isAdmin,
25
+ companyId,
26
+ }: UseAdminChangeProductsParams) {
27
+ const isInitialPrivateShuttleBooking = isPrivateShuttleProduct(initialProduct);
28
+ const [availableChangeProducts, setAvailableChangeProducts] = useState<Product[]>(
29
+ () => getAdminChangeEligibleProducts(changeProductOptions ?? [initialProduct], initialProduct),
30
+ );
31
+ const [selectedChangeProductId, setSelectedChangeProductId] = useState(initialProduct.productId);
32
+ const [changeProductsLoading, setChangeProductsLoading] = useState(false);
33
+ const [changeProductsError, setChangeProductsError] = useState<string | null>(null);
34
+
35
+ const product = useMemo(
36
+ () =>
37
+ availableChangeProducts.find((p) => p.productId === selectedChangeProductId) ??
38
+ initialProduct,
39
+ [availableChangeProducts, selectedChangeProductId, initialProduct],
40
+ );
41
+
42
+ const originalParentProductId = useMemo(() => {
43
+ const pid = initialValuesProductId?.trim();
44
+ if (pid && isParentProductId(pid)) return pid;
45
+ return initialProduct.productId;
46
+ }, [initialValuesProductId, initialProduct.productId]);
47
+
48
+ useEffect(() => {
49
+ if (changeProductOptions?.length) {
50
+ const nextProducts = getAdminChangeEligibleProducts(changeProductOptions, initialProduct);
51
+ setAvailableChangeProducts(nextProducts);
52
+ setSelectedChangeProductId((current) =>
53
+ nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
54
+ );
55
+ return;
56
+ }
57
+ setAvailableChangeProducts((prev) => {
58
+ const hasInitial = prev.some((p) => p.productId === initialProduct.productId);
59
+ return hasInitial ? prev : [initialProduct, ...prev];
60
+ });
61
+ setSelectedChangeProductId((current) => current || initialProduct.productId);
62
+ }, [initialProduct, changeProductOptions]);
63
+
64
+ useEffect(() => {
65
+ if (changeProductOptions?.length) return;
66
+ if (!isAdmin || !companyId) return;
67
+ let cancelled = false;
68
+ setChangeProductsLoading(true);
69
+ setChangeProductsError(null);
70
+ fetchProducts(companyId)
71
+ .then((products) => {
72
+ if (cancelled) return;
73
+ const nextProducts = getAdminChangeEligibleProducts(products, initialProduct);
74
+ setAvailableChangeProducts(nextProducts);
75
+ setSelectedChangeProductId((current) =>
76
+ nextProducts.some((p) => p.productId === current) ? current : initialProduct.productId,
77
+ );
78
+ })
79
+ .catch((err) => {
80
+ if (cancelled) return;
81
+ setChangeProductsError(err instanceof Error ? err.message : 'Failed to load products');
82
+ })
83
+ .finally(() => {
84
+ if (!cancelled) setChangeProductsLoading(false);
85
+ });
86
+ return () => {
87
+ cancelled = true;
88
+ };
89
+ }, [changeProductOptions, isAdmin, companyId, initialProduct]);
90
+
91
+ return {
92
+ availableChangeProducts,
93
+ changeProductsError,
94
+ changeProductsLoading,
95
+ isInitialPrivateShuttleBooking,
96
+ originalParentProductId,
97
+ product,
98
+ selectedChangeProductId,
99
+ selectedParentProductChanged: selectedChangeProductId !== originalParentProductId,
100
+ setSelectedChangeProductId,
101
+ };
102
+ }
@@ -0,0 +1,235 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ getPromoDiscount,
4
+ type Availability,
5
+ type PricingConfig,
6
+ } from '../../lib/booking-api';
7
+ import type { OrderSummaryFeeLine, OrderSummaryTicketLine } from '../../lib/booking/pricing';
8
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
9
+ import type { Currency } from './CurrencySwitcher';
10
+ import {
11
+ deriveOriginalReceiptPromoAdjustment,
12
+ getAvailabilityOptionId,
13
+ } from './change-booking-flow-helpers';
14
+
15
+ interface UseAdminChangePromoDiscountParams {
16
+ appliedPromoCode: string | null;
17
+ selectedAvailability: Availability | null;
18
+ totalQuantity: number;
19
+ productCompanyId?: string | null;
20
+ envCompanyId?: string | null;
21
+ productId: string;
22
+ currency: Currency;
23
+ quantities: Record<string, number>;
24
+ ticketLineItemsForChangeFlowDisplay: OrderSummaryTicketLine[];
25
+ feeLineItemsWithAddOns: OrderSummaryFeeLine[];
26
+ effectiveSubtotalForCheckout: number;
27
+ lockedPromoCode: string | null;
28
+ originalReceipt: ChangeBookingFlowProps['originalReceipt'];
29
+ isTaxIncludedInPrice: boolean;
30
+ pricingConfig: PricingConfig | null;
31
+ }
32
+
33
+ export function useAdminChangePromoDiscount({
34
+ appliedPromoCode,
35
+ selectedAvailability,
36
+ totalQuantity,
37
+ productCompanyId,
38
+ envCompanyId,
39
+ productId,
40
+ currency,
41
+ quantities,
42
+ ticketLineItemsForChangeFlowDisplay,
43
+ feeLineItemsWithAddOns,
44
+ effectiveSubtotalForCheckout,
45
+ lockedPromoCode,
46
+ originalReceipt,
47
+ isTaxIncludedInPrice,
48
+ pricingConfig,
49
+ }: UseAdminChangePromoDiscountParams) {
50
+ const quantitiesSignature = useMemo(
51
+ () =>
52
+ Object.entries(quantities)
53
+ .filter(([, n]) => (n ?? 0) > 0)
54
+ .sort(([a], [b]) => a.localeCompare(b))
55
+ .map(([c, n]) => `${c}:${n}`)
56
+ .join('|'),
57
+ [quantities],
58
+ );
59
+
60
+ const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
61
+ const [isGiftCard, setIsGiftCard] = useState(false);
62
+ const [isVoucher, setIsVoucher] = useState(false);
63
+ const promoDiscountFetchGenerationRef = useRef(0);
64
+ const promoDiscountParamsRef = useRef({
65
+ selectedAvailability: null as Availability | null,
66
+ ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
67
+ effectiveSubtotal: 0,
68
+ displayedProductFeeTotal: 0,
69
+ appliedPromoCode: null as string | null,
70
+ changeBookingPromo: null as { priorAmount?: number } | null,
71
+ });
72
+
73
+ const originalReceiptPromoAdjustment = useMemo(
74
+ () => deriveOriginalReceiptPromoAdjustment(originalReceipt),
75
+ [originalReceipt],
76
+ );
77
+
78
+ const selectedAvailabilityOptionId = useMemo(
79
+ () => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''),
80
+ [selectedAvailability],
81
+ );
82
+
83
+ const promoDiscountFetchKey = useMemo(() => {
84
+ if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
85
+ const companyId = productCompanyId ?? envCompanyId;
86
+ if (!companyId) return '';
87
+ if (!selectedAvailabilityOptionId || !quantitiesSignature) return '';
88
+ return [
89
+ appliedPromoCode,
90
+ companyId,
91
+ productId,
92
+ selectedAvailabilityOptionId,
93
+ selectedAvailability.dateTime,
94
+ selectedAvailability.availabilityId ?? '',
95
+ currency,
96
+ quantitiesSignature,
97
+ ticketLineItemsForChangeFlowDisplay
98
+ .map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
99
+ .sort()
100
+ .join('|'),
101
+ String(Math.round(feeLineItemsWithAddOns.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
102
+ String(Math.round(effectiveSubtotalForCheckout * 100)),
103
+ lockedPromoCode ? String(Math.round(originalReceiptPromoAdjustment.amount * 100)) : '',
104
+ ].join('::');
105
+ }, [
106
+ appliedPromoCode,
107
+ selectedAvailability,
108
+ selectedAvailabilityOptionId,
109
+ totalQuantity,
110
+ productCompanyId,
111
+ envCompanyId,
112
+ productId,
113
+ currency,
114
+ quantitiesSignature,
115
+ ticketLineItemsForChangeFlowDisplay,
116
+ feeLineItemsWithAddOns,
117
+ effectiveSubtotalForCheckout,
118
+ lockedPromoCode,
119
+ originalReceiptPromoAdjustment.amount,
120
+ ]);
121
+
122
+ promoDiscountParamsRef.current = {
123
+ selectedAvailability,
124
+ ticketLineItems: ticketLineItemsForChangeFlowDisplay.map((line) => ({
125
+ category: line.category,
126
+ qty: line.qty,
127
+ itemTotal: line.itemTotal,
128
+ })),
129
+ effectiveSubtotal: effectiveSubtotalForCheckout,
130
+ displayedProductFeeTotal: feeLineItemsWithAddOns.reduce((sum, fee) => sum + fee.totalAmount, 0),
131
+ appliedPromoCode,
132
+ changeBookingPromo: lockedPromoCode
133
+ ? { priorAmount: originalReceiptPromoAdjustment.amount }
134
+ : null,
135
+ };
136
+
137
+ useEffect(() => {
138
+ if (!promoDiscountFetchKey) {
139
+ setPromoDiscountAmount(0);
140
+ setIsGiftCard(false);
141
+ setIsVoucher(false);
142
+ return;
143
+ }
144
+
145
+ let cancelled = false;
146
+ const debounceMs = 120;
147
+ const timer = window.setTimeout(() => {
148
+ if (cancelled) return;
149
+ const {
150
+ selectedAvailability: sel,
151
+ ticketLineItems: lines,
152
+ effectiveSubtotal: sub,
153
+ displayedProductFeeTotal,
154
+ appliedPromoCode: code,
155
+ changeBookingPromo,
156
+ } = promoDiscountParamsRef.current;
157
+ if (!code || !sel) return;
158
+ const companyId = productCompanyId ?? envCompanyId;
159
+ const optionId = getAvailabilityOptionId(sel);
160
+ if (!companyId || !optionId) return;
161
+ const items = lines.map((line) => ({ category: line.category, qty: line.qty }));
162
+ if (items.length === 0) return;
163
+
164
+ const generation = ++promoDiscountFetchGenerationRef.current;
165
+ getPromoDiscount(
166
+ code,
167
+ companyId,
168
+ productId,
169
+ optionId,
170
+ currency,
171
+ items,
172
+ sel.dateTime,
173
+ sub,
174
+ changeBookingPromo
175
+ ? {
176
+ forBookingChange: true,
177
+ priorPromoDiscountAmount: changeBookingPromo.priorAmount,
178
+ displayedTicketLines: lines,
179
+ displayedProductFeeTotal,
180
+ }
181
+ : {
182
+ displayedTicketLines: lines,
183
+ displayedProductFeeTotal,
184
+ },
185
+ )
186
+ .then((res) => {
187
+ if (cancelled) return;
188
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
189
+ setPromoDiscountAmount(res.discount ?? 0);
190
+ setIsGiftCard(res.isGiftCard ?? false);
191
+ setIsVoucher(res.isVoucher ?? false);
192
+ })
193
+ .catch(() => {
194
+ if (cancelled) return;
195
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
196
+ setPromoDiscountAmount(0);
197
+ setIsGiftCard(false);
198
+ setIsVoucher(false);
199
+ });
200
+ }, debounceMs);
201
+
202
+ return () => {
203
+ cancelled = true;
204
+ window.clearTimeout(timer);
205
+ };
206
+ }, [promoDiscountFetchKey, productCompanyId, envCompanyId, productId, currency]);
207
+
208
+ const receiptPromoFallbackAmount = Math.max(0, originalReceiptPromoAdjustment.amount);
209
+ const effectivePromoDiscountAmount =
210
+ promoDiscountAmount > 0 ? promoDiscountAmount : receiptPromoFallbackAmount;
211
+ const effectivePromoLineType =
212
+ isGiftCard || originalReceiptPromoAdjustment.type === 'GIFT_CARD'
213
+ ? 'GIFT_CARD'
214
+ : isVoucher || originalReceiptPromoAdjustment.type === 'VOUCHER'
215
+ ? 'VOUCHER'
216
+ : originalReceiptPromoAdjustment.type;
217
+ const taxOnSubtotal = isTaxIncludedInPrice
218
+ ? 0
219
+ : effectiveSubtotalForCheckout * (pricingConfig?.taxRate ?? 0);
220
+ const effectiveTax =
221
+ effectivePromoDiscountAmount > 0 &&
222
+ effectivePromoLineType !== 'GIFT_CARD' &&
223
+ effectivePromoLineType !== 'VOUCHER'
224
+ ? (effectiveSubtotalForCheckout - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
225
+ : taxOnSubtotal;
226
+ const totalPrice = effectiveSubtotalForCheckout + effectiveTax - effectivePromoDiscountAmount;
227
+
228
+ return {
229
+ originalReceiptPromoAdjustment,
230
+ effectivePromoDiscountAmount,
231
+ effectivePromoLineType,
232
+ effectiveTax,
233
+ totalPrice,
234
+ };
235
+ }