@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,57 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export type AdminCustomReceiptLine = {
4
+ id: string;
5
+ label: string;
6
+ amountInput: string;
7
+ amountSign: 1 | -1;
8
+ };
9
+
10
+ type AdminCustomReceiptLinePatch = Partial<
11
+ Pick<AdminCustomReceiptLine, 'label' | 'amountInput' | 'amountSign'>
12
+ >;
13
+
14
+ export function useAdminCustomReceiptLines(resetKey: string | null | undefined) {
15
+ const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<AdminCustomReceiptLine[]>([]);
16
+ const adminCustomLineIdRef = useRef(0);
17
+ const previousResetKeyRef = useRef(resetKey);
18
+
19
+ const handleAddAdminCustomReceiptLine = useCallback(() => {
20
+ adminCustomLineIdRef.current += 1;
21
+ setAdminCustomReceiptLines((prev) => [
22
+ ...prev,
23
+ {
24
+ id: `admin-rcpt-${adminCustomLineIdRef.current}`,
25
+ label: '',
26
+ amountInput: '',
27
+ amountSign: 1,
28
+ },
29
+ ]);
30
+ }, []);
31
+
32
+ const handleUpdateAdminCustomReceiptLine = useCallback(
33
+ (id: string, patch: AdminCustomReceiptLinePatch) => {
34
+ setAdminCustomReceiptLines((prev) =>
35
+ prev.map((line) => (line.id === id ? { ...line, ...patch } : line))
36
+ );
37
+ },
38
+ [],
39
+ );
40
+
41
+ const handleRemoveAdminCustomReceiptLine = useCallback((id: string) => {
42
+ setAdminCustomReceiptLines((prev) => prev.filter((line) => line.id !== id));
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ if (previousResetKeyRef.current === resetKey) return;
47
+ previousResetKeyRef.current = resetKey;
48
+ setAdminCustomReceiptLines([]);
49
+ }, [resetKey]);
50
+
51
+ return {
52
+ adminCustomReceiptLines,
53
+ handleAddAdminCustomReceiptLine,
54
+ handleUpdateAdminCustomReceiptLine,
55
+ handleRemoveAdminCustomReceiptLine,
56
+ };
57
+ }
@@ -0,0 +1,146 @@
1
+ import { useMemo } from 'react';
2
+ import { roundMoney } from '../../lib/booking/change-flow-pricing';
3
+ import type {
4
+ ProviderDashboardChangePricingUi,
5
+ ProviderDashboardPricingLine,
6
+ } from './booking-flow-ui';
7
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
8
+
9
+ type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
10
+ type ProviderLineOverride = NonNullable<
11
+ NonNullable<ProviderDashboardChangeBookingPayload['pricingAdjustment']>['lineOverrides']
12
+ >[number];
13
+ type ProviderAdditionalAdjustment = NonNullable<
14
+ NonNullable<ProviderDashboardChangeBookingPayload['pricingAdjustment']>['additionalAdjustments']
15
+ >[number];
16
+
17
+ export interface UseAdminProviderPricingAdjustmentsParams {
18
+ isProviderDashboardChange: boolean;
19
+ showProviderPricingInlineEditor: boolean;
20
+ providerPricingUi?: ProviderDashboardChangePricingUi;
21
+ providerQuotedLines: ProviderDashboardPricingLine[];
22
+ hasChangeSelection: boolean;
23
+ editableSummaryPreSubtotalDelta: number;
24
+ adminCustomAdjustmentTotal: number;
25
+ adminCustomReceiptLines: AdminCustomReceiptLine[];
26
+ isManualOverrideEligibleLine: (line: ProviderDashboardPricingLine) => boolean;
27
+ }
28
+
29
+ function parseProviderLineAmount(raw: string | undefined, fallbackAmount: number): number | null {
30
+ const parsed = raw == null || raw.trim() === '' ? fallbackAmount : Number(raw);
31
+ if (!Number.isFinite(parsed)) return null;
32
+ return Math.round(parsed * 100) / 100;
33
+ }
34
+
35
+ export function useAdminProviderPricingAdjustments({
36
+ isProviderDashboardChange,
37
+ showProviderPricingInlineEditor,
38
+ providerPricingUi,
39
+ providerQuotedLines,
40
+ hasChangeSelection,
41
+ editableSummaryPreSubtotalDelta,
42
+ adminCustomAdjustmentTotal,
43
+ adminCustomReceiptLines,
44
+ isManualOverrideEligibleLine,
45
+ }: UseAdminProviderPricingAdjustmentsParams) {
46
+ const providerTotalsPreview =
47
+ showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
48
+ ? providerPricingUi.totalsPreview
49
+ : null;
50
+
51
+ const providerPricingOverrides = useMemo(
52
+ (): ProviderLineOverride[] =>
53
+ isProviderDashboardChange && providerQuotedLines.length > 0
54
+ ? providerQuotedLines
55
+ .filter((line) => isManualOverrideEligibleLine(line))
56
+ .map((line): ProviderLineOverride | null => {
57
+ const rounded = parseProviderLineAmount(
58
+ providerPricingUi?.lineAmountInputs?.[line.lineKey],
59
+ line.amount,
60
+ );
61
+ return rounded != null && Math.abs(rounded - line.amount) > 0.0001
62
+ ? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
63
+ : null;
64
+ })
65
+ .filter((v): v is ProviderLineOverride => v != null)
66
+ : [],
67
+ [
68
+ isProviderDashboardChange,
69
+ providerQuotedLines,
70
+ providerPricingUi?.lineAmountInputs,
71
+ isManualOverrideEligibleLine,
72
+ ],
73
+ );
74
+
75
+ const providerHasAdditionalAdjustments = useMemo(
76
+ () =>
77
+ isProviderDashboardChange &&
78
+ (providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
79
+ const parsed = Number((adj.amountInput ?? '').trim());
80
+ const hasAmount = Number.isFinite(parsed) && parsed > 0;
81
+ const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
82
+ const originalAmount = adj.originalAmountInput ?? '';
83
+ const currentLabel = (adj.label ?? '').trim();
84
+ const originalLabel = (adj.originalLabel ?? '').trim();
85
+ const currentMode = adj.mode;
86
+ const originalMode = adj.originalMode;
87
+ if (!originalMode) return hasAmount || currentLabel.length > 0;
88
+ return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
89
+ }),
90
+ [isProviderDashboardChange, providerPricingUi?.additionalAdjustments],
91
+ );
92
+
93
+ const providerAdditionalAdjustments = useMemo(
94
+ (): ProviderAdditionalAdjustment[] =>
95
+ isProviderDashboardChange
96
+ ? (providerPricingUi?.additionalAdjustments ?? [])
97
+ .map((adj): ProviderAdditionalAdjustment | null => {
98
+ const parsed = Number((adj.amountInput ?? '').trim());
99
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
100
+ const rounded = Math.round(parsed * 100) / 100;
101
+ const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
102
+ return {
103
+ label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
104
+ amount: signed,
105
+ };
106
+ })
107
+ .filter((v): v is ProviderAdditionalAdjustment => v != null)
108
+ : [],
109
+ [isProviderDashboardChange, providerPricingUi?.additionalAdjustments],
110
+ );
111
+
112
+ const adminCustomLinesAsAdditionalAdjustments = useMemo(
113
+ (): ProviderAdditionalAdjustment[] =>
114
+ adminCustomReceiptLines.flatMap((line) => {
115
+ const raw = line.amountInput.trim();
116
+ if (raw === '' || raw === '+' || raw === '-') return [];
117
+ const n = Number(raw);
118
+ if (!Number.isFinite(n) || Math.abs(n) < 0.0005) return [];
119
+ const sign = line.amountSign ?? 1;
120
+ const amount = roundMoney(sign * Math.abs(n));
121
+ if (Math.abs(amount) < 0.005) return [];
122
+ return [{ label: line.label.trim() || 'Adjustment', amount }];
123
+ }),
124
+ [adminCustomReceiptLines],
125
+ );
126
+
127
+ const mergedProviderAdditionalAdjustments = useMemo(
128
+ () => [...providerAdditionalAdjustments, ...adminCustomLinesAsAdditionalAdjustments],
129
+ [providerAdditionalAdjustments, adminCustomLinesAsAdditionalAdjustments],
130
+ );
131
+
132
+ const hasEffectiveChangeSelection =
133
+ hasChangeSelection ||
134
+ providerPricingOverrides.length > 0 ||
135
+ providerHasAdditionalAdjustments ||
136
+ (isProviderDashboardChange && Math.abs(editableSummaryPreSubtotalDelta) >= 0.005) ||
137
+ Math.abs(adminCustomAdjustmentTotal) >= 0.005;
138
+
139
+ return {
140
+ providerTotalsPreview,
141
+ hasEffectiveChangeSelection,
142
+ providerPricingOverrides,
143
+ adminCustomLinesAsAdditionalAdjustments,
144
+ mergedProviderAdditionalAdjustments,
145
+ };
146
+ }
@@ -0,0 +1,54 @@
1
+ import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+ import {
3
+ getAddOns,
4
+ type AddOn,
5
+ type Availability,
6
+ } from '../../lib/booking-api';
7
+ import { getAvailabilityOptionId } from './change-booking-flow-helpers';
8
+
9
+ export interface UseBookingAvailabilityAddOnsParams {
10
+ selectedAvailability: Availability | null;
11
+ companyId?: string | null;
12
+ onProductOptionChange?: () => void;
13
+ }
14
+
15
+ export interface UseBookingAvailabilityAddOnsResult {
16
+ addOns: AddOn[];
17
+ setAddOns: Dispatch<SetStateAction<AddOn[]>>;
18
+ availabilityProductOptionId: string | null;
19
+ }
20
+
21
+ export function useBookingAvailabilityAddOns({
22
+ selectedAvailability,
23
+ companyId,
24
+ onProductOptionChange,
25
+ }: UseBookingAvailabilityAddOnsParams): UseBookingAvailabilityAddOnsResult {
26
+ const [addOns, setAddOns] = useState<AddOn[]>([]);
27
+ const previousAvailabilityProductOptionIdRef = useRef<string | null>(null);
28
+ const availabilityProductOptionId = useMemo(
29
+ () => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null),
30
+ [selectedAvailability],
31
+ );
32
+
33
+ useEffect(() => {
34
+ if (!availabilityProductOptionId || !companyId) {
35
+ setAddOns([]);
36
+ onProductOptionChange?.();
37
+ return;
38
+ }
39
+ const optionChanged = previousAvailabilityProductOptionIdRef.current !== availabilityProductOptionId;
40
+ if (optionChanged) {
41
+ onProductOptionChange?.();
42
+ previousAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
43
+ }
44
+ getAddOns(companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
45
+ .then(setAddOns)
46
+ .catch(() => setAddOns([]));
47
+ }, [availabilityProductOptionId, companyId, onProductOptionChange]);
48
+
49
+ return {
50
+ addOns,
51
+ setAddOns,
52
+ availabilityProductOptionId,
53
+ };
54
+ }
@@ -0,0 +1,163 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+ import {
3
+ validatePromoCode,
4
+ type Availability,
5
+ } from '../../lib/booking-api';
6
+ import { getAvailabilityOptionId } from './change-booking-flow-helpers';
7
+
8
+ export interface UseBookingPromoAndQuantityControllerParams {
9
+ isAdmin: boolean;
10
+ effectivePartySizeCap: number;
11
+ currentTotalQuantity: number;
12
+ quantities: Record<string, number>;
13
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
14
+ changeBookingMinimumQuantities?: Record<string, number>;
15
+ selectedAvailability: Availability | null;
16
+ totalQuantity: number;
17
+ promoCodeInput: string;
18
+ appliedPromoCode: string | null;
19
+ setAppliedPromoCode: Dispatch<SetStateAction<string | null>>;
20
+ productCompanyId?: string | null;
21
+ productId: string;
22
+ clearFetchedAvailabilityRanges: () => void;
23
+ t: (key: string) => string;
24
+ setError: (error: string) => void;
25
+ }
26
+
27
+ export interface UseBookingPromoAndQuantityControllerResult {
28
+ handleQuantityChange: (category: string, delta: number) => void;
29
+ handleApplyPromo: () => Promise<void>;
30
+ }
31
+
32
+ export function useBookingPromoAndQuantityController({
33
+ isAdmin,
34
+ effectivePartySizeCap,
35
+ currentTotalQuantity,
36
+ quantities,
37
+ setQuantities,
38
+ changeBookingMinimumQuantities,
39
+ selectedAvailability,
40
+ totalQuantity,
41
+ promoCodeInput,
42
+ appliedPromoCode,
43
+ setAppliedPromoCode,
44
+ productCompanyId,
45
+ productId,
46
+ clearFetchedAvailabilityRanges,
47
+ t,
48
+ setError,
49
+ }: UseBookingPromoAndQuantityControllerParams): UseBookingPromoAndQuantityControllerResult {
50
+ const [, setPromoCodeError] = useState('');
51
+ const [, setPromoCodeValidating] = useState(false);
52
+ const promoAppliedSelectionKeyRef = useRef<string | null>(null);
53
+ const promoValidateInFlightRef = useRef(false);
54
+
55
+ const handleQuantityChange = useCallback((category: string, delta: number) => {
56
+ const maxAvailable = isAdmin ? Number.MAX_SAFE_INTEGER : effectivePartySizeCap;
57
+ const currentQty = quantities[category] || 0;
58
+ const minQ =
59
+ changeBookingMinimumQuantities != null
60
+ ? Math.max(0, changeBookingMinimumQuantities[category] ?? 0)
61
+ : 0;
62
+ const newQty = Math.max(minQ, currentQty + delta);
63
+ // Admin can overbook; non-admin cannot exceed vacancies.
64
+ if (delta > 0 && !isAdmin && currentTotalQuantity >= maxAvailable) {
65
+ return;
66
+ }
67
+ setQuantities((prev) => ({
68
+ ...prev,
69
+ [category]: newQty,
70
+ }));
71
+ setError('');
72
+ }, [
73
+ isAdmin,
74
+ effectivePartySizeCap,
75
+ quantities,
76
+ changeBookingMinimumQuantities,
77
+ currentTotalQuantity,
78
+ setQuantities,
79
+ setError,
80
+ ]);
81
+
82
+ const hasOngoingDiscount = useMemo(
83
+ () =>
84
+ selectedAvailability?.rates?.some((r) =>
85
+ (r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
86
+ ) ?? false,
87
+ [selectedAvailability],
88
+ );
89
+
90
+ const selectedAvailabilityKey = useMemo(
91
+ () => `${selectedAvailability?.dateTime ?? ''}::${selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''}`,
92
+ [selectedAvailability],
93
+ );
94
+
95
+ const handleApplyPromo = useCallback(async () => {
96
+ const code = promoCodeInput.trim().toUpperCase();
97
+ if (!code) return;
98
+ // Promo validation/application requires a concrete booking context.
99
+ if (!selectedAvailability || totalQuantity <= 0) {
100
+ return;
101
+ }
102
+ if (appliedPromoCode === code) return;
103
+ const companyId = productCompanyId;
104
+ if (!companyId) return;
105
+ if (promoValidateInFlightRef.current) return;
106
+ promoValidateInFlightRef.current = true;
107
+ setPromoCodeError('');
108
+ setPromoCodeValidating(true);
109
+ try {
110
+ const result = await validatePromoCode(code, companyId, productId, hasOngoingDiscount);
111
+ if (result.valid) {
112
+ promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
113
+ setAppliedPromoCode(code);
114
+ clearFetchedAvailabilityRanges();
115
+ } else {
116
+ const errorMsg =
117
+ result.error === 'Promo codes cannot be stacked with deals'
118
+ ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
119
+ : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
120
+ setPromoCodeError(errorMsg);
121
+ }
122
+ } catch (err) {
123
+ setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
124
+ } finally {
125
+ promoValidateInFlightRef.current = false;
126
+ setPromoCodeValidating(false);
127
+ }
128
+ }, [
129
+ promoCodeInput,
130
+ selectedAvailability,
131
+ totalQuantity,
132
+ appliedPromoCode,
133
+ productCompanyId,
134
+ productId,
135
+ hasOngoingDiscount,
136
+ selectedAvailabilityKey,
137
+ setAppliedPromoCode,
138
+ clearFetchedAvailabilityRanges,
139
+ t,
140
+ ]);
141
+
142
+ const handleApplyPromoRef = useRef(handleApplyPromo);
143
+ handleApplyPromoRef.current = handleApplyPromo;
144
+
145
+ // Auto-apply promo when user stops typing; avoid depending on validation state to prevent repeated retries.
146
+ useEffect(() => {
147
+ const trimmed = promoCodeInput.trim().toUpperCase();
148
+ if (!trimmed) return;
149
+ if (!selectedAvailability || totalQuantity <= 0) return;
150
+ if (appliedPromoCode === trimmed) return;
151
+
152
+ const timer = setTimeout(() => {
153
+ handleApplyPromoRef.current();
154
+ }, 600);
155
+
156
+ return () => clearTimeout(timer);
157
+ }, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
158
+
159
+ return {
160
+ handleQuantityChange,
161
+ handleApplyPromo,
162
+ };
163
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, type Dispatch, type SetStateAction } from 'react';
2
+ import type { Availability } from '../../lib/booking-api';
3
+
4
+ export interface UseBookingQuantityCapTrimParams {
5
+ isAdmin: boolean;
6
+ selectedAvailability: Availability | null;
7
+ effectivePartySizeCap: number;
8
+ totalQuantity: number;
9
+ minimumQuantities?: Record<string, number>;
10
+ setQuantities: Dispatch<SetStateAction<Record<string, number>>>;
11
+ }
12
+
13
+ export function useBookingQuantityCapTrim({
14
+ isAdmin,
15
+ selectedAvailability,
16
+ effectivePartySizeCap,
17
+ totalQuantity,
18
+ minimumQuantities,
19
+ setQuantities,
20
+ }: UseBookingQuantityCapTrimParams) {
21
+ useEffect(() => {
22
+ if (isAdmin || !selectedAvailability) return;
23
+ const cap = effectivePartySizeCap;
24
+ if (totalQuantity <= cap) return;
25
+ const over = totalQuantity - cap;
26
+ setQuantities((prev) => {
27
+ const next = { ...prev };
28
+ let remaining = over;
29
+ const categories = Object.keys(next)
30
+ .filter((category) => (next[category] ?? 0) > 0)
31
+ .sort((a, b) => (next[b] ?? 0) - (next[a] ?? 0));
32
+
33
+ for (const category of categories) {
34
+ if (remaining <= 0) break;
35
+ const minQuantity =
36
+ minimumQuantities != null
37
+ ? Math.max(0, minimumQuantities[category] ?? 0)
38
+ : 0;
39
+ const currentQuantity = next[category] ?? 0;
40
+ const reducible = Math.max(0, currentQuantity - minQuantity);
41
+ const decrement = Math.min(reducible, remaining);
42
+ next[category] = currentQuantity - decrement;
43
+ remaining -= decrement;
44
+ }
45
+
46
+ return next;
47
+ });
48
+ }, [
49
+ effectivePartySizeCap,
50
+ totalQuantity,
51
+ selectedAvailability,
52
+ isAdmin,
53
+ minimumQuantities,
54
+ setQuantities,
55
+ ]);
56
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { Product } from '../../lib/booking-api';
3
+ import type { Currency } from './CurrencySwitcher';
4
+
5
+ interface BookingViewItemAnalytics {
6
+ trackViewItem: (id: string, name: string, price: number, currency: Currency) => void;
7
+ }
8
+
9
+ export function useBookingViewItemAnalytics({
10
+ analytics,
11
+ product,
12
+ productId,
13
+ currency,
14
+ }: {
15
+ analytics: BookingViewItemAnalytics;
16
+ product: Product;
17
+ productId?: string;
18
+ currency: Currency;
19
+ }) {
20
+ const hasFiredViewItem = useRef(false);
21
+
22
+ useEffect(() => {
23
+ if (!hasFiredViewItem.current && product) {
24
+ hasFiredViewItem.current = true;
25
+ const id = productId || product.productId;
26
+ const price = product.minPriceByCurrency?.[currency] ?? 0;
27
+ analytics.trackViewItem(id, product.name, price, currency);
28
+ }
29
+ }, [analytics, product, productId, currency]);
30
+ }
@@ -0,0 +1,109 @@
1
+ import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
2
+ import type { AddOn } from '../../lib/booking-api';
3
+ import type { ChangeBookingFlowProps } from './booking-flow-types';
4
+ import {
5
+ deriveAddOnSelectionsFromReceiptLines,
6
+ normalizeAddOnSelections,
7
+ type AddOnSelection,
8
+ } from './change-booking-flow-helpers';
9
+
10
+ type AddOnSelectionsUpdater = AddOnSelection[] | ((prev: AddOnSelection[]) => AddOnSelection[]);
11
+
12
+ interface UseChangeBookingAddOnFloorControllerOptions {
13
+ initialValues?: ChangeBookingFlowProps['initialValues'];
14
+ originalReceiptLineItems?: NonNullable<ChangeBookingFlowProps['originalReceipt']>['lineItems'];
15
+ addOns: AddOn[];
16
+ isCustomerSelfServeChange: boolean;
17
+ setAddOnSelections: Dispatch<SetStateAction<AddOnSelection[]>>;
18
+ }
19
+
20
+ export function useChangeBookingAddOnFloorController({
21
+ initialValues,
22
+ originalReceiptLineItems,
23
+ addOns,
24
+ isCustomerSelfServeChange,
25
+ setAddOnSelections,
26
+ }: UseChangeBookingAddOnFloorControllerOptions): {
27
+ initialAddOnMinQtyByKey: Map<string, number>;
28
+ initialAddOnMinTotalByAddOnId: Map<string, number>;
29
+ updateAddOnSelections: (updater: AddOnSelectionsUpdater) => void;
30
+ } {
31
+ const initialAddOnBaselineSelections = useMemo(() => {
32
+ const initialSelections = initialValues?.addOnSelections;
33
+ if ((initialSelections?.length ?? 0) > 0) {
34
+ return normalizeAddOnSelections(initialSelections ?? []);
35
+ }
36
+ if ((originalReceiptLineItems?.length ?? 0) > 0 && addOns.length > 0) {
37
+ return normalizeAddOnSelections(deriveAddOnSelectionsFromReceiptLines(addOns, originalReceiptLineItems ?? []));
38
+ }
39
+ return [] as AddOnSelection[];
40
+ }, [initialValues?.addOnSelections, originalReceiptLineItems, addOns]);
41
+
42
+ const initialAddOnMinQtyByKey = useMemo(() => {
43
+ const map = new Map<string, number>();
44
+ if (!isCustomerSelfServeChange) return map;
45
+ for (const sel of initialAddOnBaselineSelections) {
46
+ const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
47
+ map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
48
+ }
49
+ return map;
50
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
51
+
52
+ const initialAddOnMinTotalByAddOnId = useMemo(() => {
53
+ const map = new Map<string, number>();
54
+ if (!isCustomerSelfServeChange) return map;
55
+ for (const sel of initialAddOnBaselineSelections) {
56
+ const addOnId = sel.addOnId.trim();
57
+ map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
58
+ }
59
+ return map;
60
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
61
+
62
+ const applyChangeFlowAddOnFloor = useCallback(
63
+ (nextSelections: AddOnSelection[]) => {
64
+ if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
65
+ const qtyByKey = new Map<string, number>();
66
+ for (const sel of nextSelections) {
67
+ const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
68
+ qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
69
+ }
70
+
71
+ const totalByAddOnId = new Map<string, number>();
72
+ for (const [key, qty] of qtyByKey.entries()) {
73
+ const addOnId = key.split('::')[0];
74
+ totalByAddOnId.set(addOnId, (totalByAddOnId.get(addOnId) ?? 0) + qty);
75
+ }
76
+
77
+ for (const [addOnId, minTotal] of initialAddOnMinTotalByAddOnId.entries()) {
78
+ const currentTotal = totalByAddOnId.get(addOnId) ?? 0;
79
+ if (currentTotal >= minTotal) continue;
80
+ const deficit = minTotal - currentTotal;
81
+ const existingKey = Array.from(qtyByKey.keys()).find((k) => k.startsWith(`${addOnId}::`));
82
+ const targetKey = existingKey ?? `${addOnId}::`;
83
+ qtyByKey.set(targetKey, (qtyByKey.get(targetKey) ?? 0) + deficit);
84
+ }
85
+ return Array.from(qtyByKey.entries()).map(([key, qty]) => {
86
+ const [addOnId, variantIdRaw] = key.split('::');
87
+ const variantId = variantIdRaw?.trim() || undefined;
88
+ return { addOnId, variantId, quantity: qty };
89
+ });
90
+ },
91
+ [isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
92
+ );
93
+
94
+ const updateAddOnSelections = useCallback(
95
+ (updater: AddOnSelectionsUpdater) => {
96
+ setAddOnSelections((prev) => {
97
+ const rawNext = typeof updater === 'function' ? updater(prev) : updater;
98
+ return applyChangeFlowAddOnFloor(rawNext);
99
+ });
100
+ },
101
+ [applyChangeFlowAddOnFloor, setAddOnSelections]
102
+ );
103
+
104
+ return {
105
+ initialAddOnMinQtyByKey,
106
+ initialAddOnMinTotalByAddOnId,
107
+ updateAddOnSelections,
108
+ };
109
+ }