@ticketboothapp/booking 1.2.166 → 1.2.168
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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
- package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
- package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
- package/src/components/booking/ChangeBookingFlow.tsx +6 -3
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
- package/src/components/booking/NewBookingFlow.tsx +14 -2
- package/src/components/booking/PickupLocationSelector.module.css +22 -0
- package/src/components/booking/PickupLocationSelector.tsx +3 -3
- package/src/components/booking/PriceBreakdown.tsx +66 -15
- package/src/components/booking/PriceSummary.tsx +16 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
- package/src/components/booking/admin-adjustment-components.ts +44 -0
- package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
- package/src/components/booking/admin-change-provider-payload.ts +12 -1
- package/src/components/booking/admin-change-quote-request-key.ts +41 -1
- package/src/components/booking/admin-change-receipt-lines.ts +49 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
- package/src/components/booking/admin-refund-decision-context.ts +122 -0
- package/src/components/booking/admin-refund-disposition.ts +71 -2
- package/src/components/booking/availability-date-selection.ts +16 -0
- package/src/components/booking/booking-flow-types.ts +4 -0
- package/src/components/booking/booking-flow.css +5 -0
- package/src/components/booking/change-booking-error-message.ts +137 -0
- package/src/components/booking/change-booking-flow-helpers.ts +77 -2
- package/src/components/booking/change-booking-quote-guards.ts +4 -1
- package/src/components/booking/change-booking-quote-state.ts +44 -1
- package/src/components/booking/incompatible-add-on-selections.ts +19 -0
- package/src/components/booking/private-shuttle-availability.ts +14 -0
- package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
- package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
- package/src/components/booking/use-standard-booking-availability.ts +5 -2
- package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
- package/src/components/booking/useAdminChangeProductReset.ts +6 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
- package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
- package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
- package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
- package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
- package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
- package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
- package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
- package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
- package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
- package/src/lib/booking/booking-cutoffs.ts +22 -0
- package/src/lib/booking/change-booking-server-preview.ts +92 -5
- package/src/lib/booking/change-flow-pricing.ts +12 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking-api.ts +76 -1
- package/test/change-booking-helpers.test.ts +1319 -7
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
|
@@ -5,17 +5,8 @@ import type {
|
|
|
5
5
|
ProviderDashboardPricingLine,
|
|
6
6
|
} from './booking-flow-ui';
|
|
7
7
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
8
|
+
import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
|
|
8
9
|
|
|
9
|
-
type AdminCustomReceiptLine = {
|
|
10
|
-
id: string;
|
|
11
|
-
label: string;
|
|
12
|
-
amountInput: string;
|
|
13
|
-
amountSign?: number;
|
|
14
|
-
calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
|
|
15
|
-
scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
|
|
16
|
-
taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
|
|
17
|
-
reasonCode: string;
|
|
18
|
-
};
|
|
19
10
|
type AdminStructuredAdjustment = NonNullable<
|
|
20
11
|
ProviderDashboardChangeBookingPayload['structuredAdjustments']
|
|
21
12
|
>[number];
|
|
@@ -26,6 +17,35 @@ type ProviderAdditionalAdjustment = NonNullable<
|
|
|
26
17
|
NonNullable<ProviderDashboardChangeBookingPayload['pricingAdjustment']>['additionalAdjustments']
|
|
27
18
|
>[number];
|
|
28
19
|
|
|
20
|
+
export interface EffectiveAdminChangeSelectionInput {
|
|
21
|
+
hasChangeSelection: boolean;
|
|
22
|
+
providerPricingOverrideCount: number;
|
|
23
|
+
providerHasAdditionalAdjustments: boolean;
|
|
24
|
+
isProviderDashboardChange: boolean;
|
|
25
|
+
editableSummaryPreSubtotalDelta: number;
|
|
26
|
+
adminStructuredAdjustmentCount: number;
|
|
27
|
+
adminReverseAdjustmentCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasEffectiveAdminChangeSelection({
|
|
31
|
+
hasChangeSelection,
|
|
32
|
+
providerPricingOverrideCount,
|
|
33
|
+
providerHasAdditionalAdjustments,
|
|
34
|
+
isProviderDashboardChange,
|
|
35
|
+
editableSummaryPreSubtotalDelta,
|
|
36
|
+
adminStructuredAdjustmentCount,
|
|
37
|
+
adminReverseAdjustmentCount = 0,
|
|
38
|
+
}: EffectiveAdminChangeSelectionInput): boolean {
|
|
39
|
+
return Boolean(
|
|
40
|
+
hasChangeSelection ||
|
|
41
|
+
providerPricingOverrideCount > 0 ||
|
|
42
|
+
providerHasAdditionalAdjustments ||
|
|
43
|
+
(isProviderDashboardChange && Math.abs(editableSummaryPreSubtotalDelta) >= 0.005) ||
|
|
44
|
+
adminStructuredAdjustmentCount > 0 ||
|
|
45
|
+
adminReverseAdjustmentCount > 0,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
export interface UseAdminProviderPricingAdjustmentsParams {
|
|
30
50
|
isProviderDashboardChange: boolean;
|
|
31
51
|
showProviderPricingInlineEditor: boolean;
|
|
@@ -33,8 +53,8 @@ export interface UseAdminProviderPricingAdjustmentsParams {
|
|
|
33
53
|
providerQuotedLines: ProviderDashboardPricingLine[];
|
|
34
54
|
hasChangeSelection: boolean;
|
|
35
55
|
editableSummaryPreSubtotalDelta: number;
|
|
36
|
-
adminCustomAdjustmentTotal: number;
|
|
37
56
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
57
|
+
adminReverseAdjustmentIds: string[];
|
|
38
58
|
isManualOverrideEligibleLine: (line: ProviderDashboardPricingLine) => boolean;
|
|
39
59
|
}
|
|
40
60
|
|
|
@@ -51,8 +71,8 @@ export function useAdminProviderPricingAdjustments({
|
|
|
51
71
|
providerQuotedLines,
|
|
52
72
|
hasChangeSelection,
|
|
53
73
|
editableSummaryPreSubtotalDelta,
|
|
54
|
-
adminCustomAdjustmentTotal,
|
|
55
74
|
adminCustomReceiptLines,
|
|
75
|
+
adminReverseAdjustmentIds,
|
|
56
76
|
isManualOverrideEligibleLine,
|
|
57
77
|
}: UseAdminProviderPricingAdjustmentsParams) {
|
|
58
78
|
const [adjustmentIdNamespace] = useState(() =>
|
|
@@ -127,6 +147,7 @@ export function useAdminProviderPricingAdjustments({
|
|
|
127
147
|
const adminCustomLinesAsAdditionalAdjustments = useMemo(
|
|
128
148
|
(): ProviderAdditionalAdjustment[] =>
|
|
129
149
|
adminCustomReceiptLines.flatMap((line) => {
|
|
150
|
+
if (line.sourceAdjustmentId) return [];
|
|
130
151
|
const raw = line.amountInput.trim();
|
|
131
152
|
if (raw === '' || raw === '+' || raw === '-') return [];
|
|
132
153
|
const n = Number(raw);
|
|
@@ -142,20 +163,26 @@ export function useAdminProviderPricingAdjustments({
|
|
|
142
163
|
const adminStructuredAdjustments = useMemo(
|
|
143
164
|
(): AdminStructuredAdjustment[] => {
|
|
144
165
|
const customAdjustments = adminCustomReceiptLines.flatMap((line) => {
|
|
166
|
+
if (line.sourceAdjustmentId && !line.isDirty) return [];
|
|
145
167
|
const value = Number(line.amountInput.trim());
|
|
146
168
|
if (!Number.isFinite(value) || value <= 0) return [];
|
|
169
|
+
if (line.scope === 'SELECTED_COMPONENTS' && line.selectedComponentIds.length === 0) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
147
172
|
const discount = (line.amountSign ?? 1) < 0;
|
|
148
173
|
const percentage = line.calculation === 'PERCENTAGE';
|
|
149
174
|
const mode: AdminStructuredAdjustment['mode'] = percentage
|
|
150
175
|
? discount ? 'PERCENTAGE_DISCOUNT' : 'PERCENTAGE_SURCHARGE'
|
|
151
176
|
: 'FIXED_AMOUNT';
|
|
152
177
|
return [{
|
|
153
|
-
adjustmentId: line.id,
|
|
178
|
+
adjustmentId: line.replacementAdjustmentId ?? line.id,
|
|
154
179
|
mode,
|
|
155
180
|
...(percentage
|
|
156
181
|
? { percentageBasisPoints: Math.round(value * 100) }
|
|
157
182
|
: { fixedAmount: roundMoney((discount ? -1 : 1) * value) }),
|
|
158
183
|
scope: line.scope,
|
|
184
|
+
selectedComponentIds:
|
|
185
|
+
line.scope === 'SELECTED_COMPONENTS' ? line.selectedComponentIds : [],
|
|
159
186
|
taxBehavior: line.taxBehavior,
|
|
160
187
|
reasonCode: line.reasonCode.trim() || 'STAFF_ADJUSTMENT',
|
|
161
188
|
reason: line.label.trim() || null,
|
|
@@ -209,12 +236,15 @@ export function useAdminProviderPricingAdjustments({
|
|
|
209
236
|
[providerAdditionalAdjustments],
|
|
210
237
|
);
|
|
211
238
|
|
|
212
|
-
const hasEffectiveChangeSelection =
|
|
213
|
-
hasChangeSelection
|
|
214
|
-
providerPricingOverrides.length
|
|
215
|
-
providerHasAdditionalAdjustments
|
|
216
|
-
|
|
217
|
-
|
|
239
|
+
const hasEffectiveChangeSelection = hasEffectiveAdminChangeSelection({
|
|
240
|
+
hasChangeSelection,
|
|
241
|
+
providerPricingOverrideCount: providerPricingOverrides.length,
|
|
242
|
+
providerHasAdditionalAdjustments,
|
|
243
|
+
isProviderDashboardChange,
|
|
244
|
+
editableSummaryPreSubtotalDelta,
|
|
245
|
+
adminStructuredAdjustmentCount: adminStructuredAdjustments.length,
|
|
246
|
+
adminReverseAdjustmentCount: adminReverseAdjustmentIds.length,
|
|
247
|
+
});
|
|
218
248
|
|
|
219
249
|
return {
|
|
220
250
|
providerTotalsPreview,
|
|
@@ -223,5 +253,6 @@ export function useAdminProviderPricingAdjustments({
|
|
|
223
253
|
adminCustomLinesAsAdditionalAdjustments,
|
|
224
254
|
mergedProviderAdditionalAdjustments,
|
|
225
255
|
adminStructuredAdjustments,
|
|
256
|
+
adminReverseAdjustmentIds,
|
|
226
257
|
};
|
|
227
258
|
}
|
|
@@ -16,6 +16,7 @@ export interface UseBookingAvailabilityAddOnsResult {
|
|
|
16
16
|
addOns: AddOn[];
|
|
17
17
|
setAddOns: Dispatch<SetStateAction<AddOn[]>>;
|
|
18
18
|
availabilityProductOptionId: string | null;
|
|
19
|
+
loadedProductOptionId: string | null;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function useBookingAvailabilityAddOns({
|
|
@@ -24,15 +25,19 @@ export function useBookingAvailabilityAddOns({
|
|
|
24
25
|
onProductOptionChange,
|
|
25
26
|
}: UseBookingAvailabilityAddOnsParams): UseBookingAvailabilityAddOnsResult {
|
|
26
27
|
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
28
|
+
const [loadedProductOptionId, setLoadedProductOptionId] = useState<string | null>(null);
|
|
27
29
|
const previousAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
28
30
|
const availabilityProductOptionId = useMemo(
|
|
29
31
|
() => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null),
|
|
30
32
|
[selectedAvailability],
|
|
31
33
|
);
|
|
34
|
+
const availabilityDateTime = selectedAvailability?.dateTime?.trim() || null;
|
|
32
35
|
|
|
33
36
|
useEffect(() => {
|
|
37
|
+
let cancelled = false;
|
|
34
38
|
if (!availabilityProductOptionId || !companyId) {
|
|
35
39
|
setAddOns([]);
|
|
40
|
+
setLoadedProductOptionId(null);
|
|
36
41
|
onProductOptionChange?.();
|
|
37
42
|
return;
|
|
38
43
|
}
|
|
@@ -41,14 +46,31 @@ export function useBookingAvailabilityAddOns({
|
|
|
41
46
|
onProductOptionChange?.();
|
|
42
47
|
previousAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
|
|
43
48
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
setLoadedProductOptionId(null);
|
|
50
|
+
getAddOns(companyId, {
|
|
51
|
+
productOptionId: availabilityProductOptionId,
|
|
52
|
+
preCheckout: true,
|
|
53
|
+
dateTime: availabilityDateTime ?? undefined,
|
|
54
|
+
})
|
|
55
|
+
.then((rows) => {
|
|
56
|
+
if (cancelled) return;
|
|
57
|
+
setAddOns(rows);
|
|
58
|
+
setLoadedProductOptionId(availabilityProductOptionId);
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
if (cancelled) return;
|
|
62
|
+
setAddOns([]);
|
|
63
|
+
setLoadedProductOptionId(availabilityProductOptionId);
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [availabilityProductOptionId, availabilityDateTime, companyId, onProductOptionChange]);
|
|
48
69
|
|
|
49
70
|
return {
|
|
50
71
|
addOns,
|
|
51
72
|
setAddOns,
|
|
52
73
|
availabilityProductOptionId,
|
|
74
|
+
loadedProductOptionId,
|
|
53
75
|
};
|
|
54
76
|
}
|
|
@@ -27,6 +27,9 @@ export interface UseBookingPromoAndQuantityControllerParams {
|
|
|
27
27
|
export interface UseBookingPromoAndQuantityControllerResult {
|
|
28
28
|
handleQuantityChange: (category: string, delta: number) => void;
|
|
29
29
|
handleApplyPromo: () => Promise<void>;
|
|
30
|
+
handleRemovePromo: () => void;
|
|
31
|
+
promoCodeError: string;
|
|
32
|
+
promoCodeValidating: boolean;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function useBookingPromoAndQuantityController({
|
|
@@ -47,8 +50,8 @@ export function useBookingPromoAndQuantityController({
|
|
|
47
50
|
t,
|
|
48
51
|
setError,
|
|
49
52
|
}: UseBookingPromoAndQuantityControllerParams): UseBookingPromoAndQuantityControllerResult {
|
|
50
|
-
const [, setPromoCodeError] = useState('');
|
|
51
|
-
const [, setPromoCodeValidating] = useState(false);
|
|
53
|
+
const [promoCodeError, setPromoCodeError] = useState('');
|
|
54
|
+
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
52
55
|
const promoAppliedSelectionKeyRef = useRef<string | null>(null);
|
|
53
56
|
const promoValidateInFlightRef = useRef(false);
|
|
54
57
|
|
|
@@ -156,8 +159,19 @@ export function useBookingPromoAndQuantityController({
|
|
|
156
159
|
return () => clearTimeout(timer);
|
|
157
160
|
}, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
|
|
158
161
|
|
|
162
|
+
const handleRemovePromo = useCallback(() => {
|
|
163
|
+
promoAppliedSelectionKeyRef.current = null;
|
|
164
|
+
setAppliedPromoCode(null);
|
|
165
|
+
setPromoCodeError('');
|
|
166
|
+
setPromoCodeValidating(false);
|
|
167
|
+
clearFetchedAvailabilityRanges();
|
|
168
|
+
}, [clearFetchedAvailabilityRanges, setAppliedPromoCode]);
|
|
169
|
+
|
|
159
170
|
return {
|
|
160
171
|
handleQuantityChange,
|
|
161
172
|
handleApplyPromo,
|
|
173
|
+
handleRemovePromo,
|
|
174
|
+
promoCodeError,
|
|
175
|
+
promoCodeValidating,
|
|
162
176
|
};
|
|
163
177
|
}
|
|
@@ -13,7 +13,7 @@ interface UseChangeBookingAddOnFloorControllerOptions {
|
|
|
13
13
|
initialValues?: ChangeBookingFlowProps['initialValues'];
|
|
14
14
|
originalReceiptLineItems?: NonNullable<ChangeBookingFlowProps['originalReceipt']>['lineItems'];
|
|
15
15
|
addOns: AddOn[];
|
|
16
|
-
|
|
16
|
+
lockExistingAddOnQuantities: boolean;
|
|
17
17
|
setAddOnSelections: Dispatch<SetStateAction<AddOnSelection[]>>;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -21,9 +21,10 @@ export function useChangeBookingAddOnFloorController({
|
|
|
21
21
|
initialValues,
|
|
22
22
|
originalReceiptLineItems,
|
|
23
23
|
addOns,
|
|
24
|
-
|
|
24
|
+
lockExistingAddOnQuantities,
|
|
25
25
|
setAddOnSelections,
|
|
26
26
|
}: UseChangeBookingAddOnFloorControllerOptions): {
|
|
27
|
+
initialAddOnQtyByKey: Map<string, number>;
|
|
27
28
|
initialAddOnMinQtyByKey: Map<string, number>;
|
|
28
29
|
initialAddOnMinTotalByAddOnId: Map<string, number>;
|
|
29
30
|
updateAddOnSelections: (updater: AddOnSelectionsUpdater) => void;
|
|
@@ -39,29 +40,33 @@ export function useChangeBookingAddOnFloorController({
|
|
|
39
40
|
return [] as AddOnSelection[];
|
|
40
41
|
}, [initialValues?.addOnSelections, originalReceiptLineItems, addOns]);
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
+
const initialAddOnQtyByKey = useMemo(() => {
|
|
43
44
|
const map = new Map<string, number>();
|
|
44
|
-
if (!isCustomerSelfServeChange) return map;
|
|
45
45
|
for (const sel of initialAddOnBaselineSelections) {
|
|
46
46
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
47
47
|
map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
48
48
|
}
|
|
49
49
|
return map;
|
|
50
|
-
}, [
|
|
50
|
+
}, [initialAddOnBaselineSelections]);
|
|
51
|
+
|
|
52
|
+
const initialAddOnMinQtyByKey = useMemo(
|
|
53
|
+
() => (lockExistingAddOnQuantities ? initialAddOnQtyByKey : new Map<string, number>()),
|
|
54
|
+
[lockExistingAddOnQuantities, initialAddOnQtyByKey]
|
|
55
|
+
);
|
|
51
56
|
|
|
52
57
|
const initialAddOnMinTotalByAddOnId = useMemo(() => {
|
|
53
58
|
const map = new Map<string, number>();
|
|
54
|
-
if (!
|
|
59
|
+
if (!lockExistingAddOnQuantities) return map;
|
|
55
60
|
for (const sel of initialAddOnBaselineSelections) {
|
|
56
61
|
const addOnId = sel.addOnId.trim();
|
|
57
62
|
map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
58
63
|
}
|
|
59
64
|
return map;
|
|
60
|
-
}, [
|
|
65
|
+
}, [lockExistingAddOnQuantities, initialAddOnBaselineSelections]);
|
|
61
66
|
|
|
62
67
|
const applyChangeFlowAddOnFloor = useCallback(
|
|
63
68
|
(nextSelections: AddOnSelection[]) => {
|
|
64
|
-
if (!
|
|
69
|
+
if (!lockExistingAddOnQuantities || initialAddOnMinQtyByKey.size === 0) return nextSelections;
|
|
65
70
|
const qtyByKey = new Map<string, number>();
|
|
66
71
|
for (const sel of nextSelections) {
|
|
67
72
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
@@ -88,7 +93,7 @@ export function useChangeBookingAddOnFloorController({
|
|
|
88
93
|
return { addOnId, variantId, quantity: qty };
|
|
89
94
|
});
|
|
90
95
|
},
|
|
91
|
-
[
|
|
96
|
+
[lockExistingAddOnQuantities, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
|
|
92
97
|
);
|
|
93
98
|
|
|
94
99
|
const updateAddOnSelections = useCallback(
|
|
@@ -102,6 +107,7 @@ export function useChangeBookingAddOnFloorController({
|
|
|
102
107
|
);
|
|
103
108
|
|
|
104
109
|
return {
|
|
110
|
+
initialAddOnQtyByKey,
|
|
105
111
|
initialAddOnMinQtyByKey,
|
|
106
112
|
initialAddOnMinTotalByAddOnId,
|
|
107
113
|
updateAddOnSelections,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, type RefObject } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
|
4
4
|
import type { Availability, PickupLocation } from '../../lib/booking-api';
|
|
5
|
+
import {
|
|
6
|
+
findFirstDateWithBookableAvailability,
|
|
7
|
+
selectedDateHasVisibleAvailability,
|
|
8
|
+
} from './availability-date-selection';
|
|
5
9
|
|
|
6
10
|
export interface UseChangeBookingAutoSelectionsOptions {
|
|
7
11
|
autoSelectFirstAvailableDate?: boolean;
|
|
@@ -71,6 +75,55 @@ export function useChangeBookingAutoSelections({
|
|
|
71
75
|
const hasAutoSelectedPartnerDateRef = useRef(false);
|
|
72
76
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
73
77
|
|
|
78
|
+
const isBookableForAutoSelection = useCallback(
|
|
79
|
+
(availability: Availability): boolean => {
|
|
80
|
+
if (isAdmin) return (availability.vacancies ?? 0) > 0;
|
|
81
|
+
if (isCustomerSelfServeChange && changeFlowInitialTicketCount > 0) {
|
|
82
|
+
return getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount;
|
|
83
|
+
}
|
|
84
|
+
return (availability.vacancies ?? 0) > 0;
|
|
85
|
+
},
|
|
86
|
+
[
|
|
87
|
+
changeFlowInitialTicketCount,
|
|
88
|
+
getCalendarEffectiveOutboundVacancies,
|
|
89
|
+
isAdmin,
|
|
90
|
+
isCustomerSelfServeChange,
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!autoSelectFirstAvailableDate) return;
|
|
96
|
+
if (isPartialLaunch) return;
|
|
97
|
+
if (!selectedDate || dates.length === 0) return;
|
|
98
|
+
if (selectedDateHasVisibleAvailability(selectedDate, getRowsForDate)) return;
|
|
99
|
+
|
|
100
|
+
const first = findFirstDateWithBookableAvailability(
|
|
101
|
+
dates,
|
|
102
|
+
getRowsForDate,
|
|
103
|
+
isBookableForAutoSelection,
|
|
104
|
+
);
|
|
105
|
+
if (!first || first === selectedDate) return;
|
|
106
|
+
|
|
107
|
+
hasAutoSelectedPartnerDateRef.current = true;
|
|
108
|
+
setSelectedDate(first);
|
|
109
|
+
onDateSelect(first);
|
|
110
|
+
if (!suppressCalendarDateScroll) {
|
|
111
|
+
scrollAfterCalendarSelection(contentRef, useWindowScroll);
|
|
112
|
+
}
|
|
113
|
+
}, [
|
|
114
|
+
autoSelectFirstAvailableDate,
|
|
115
|
+
isPartialLaunch,
|
|
116
|
+
selectedDate,
|
|
117
|
+
dates,
|
|
118
|
+
getRowsForDate,
|
|
119
|
+
isBookableForAutoSelection,
|
|
120
|
+
setSelectedDate,
|
|
121
|
+
onDateSelect,
|
|
122
|
+
suppressCalendarDateScroll,
|
|
123
|
+
contentRef,
|
|
124
|
+
useWindowScroll,
|
|
125
|
+
]);
|
|
126
|
+
|
|
74
127
|
useEffect(() => {
|
|
75
128
|
if (!autoSelectFirstAvailableDate) return;
|
|
76
129
|
if (isPartialLaunch) return;
|
|
@@ -79,18 +132,11 @@ export function useChangeBookingAutoSelections({
|
|
|
79
132
|
if (dates.length === 0) return;
|
|
80
133
|
if (hasAutoSelectedPartnerDateRef.current) return;
|
|
81
134
|
|
|
82
|
-
const firstWithInventory =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return rows.some(
|
|
88
|
-
(availability) =>
|
|
89
|
-
getCalendarEffectiveOutboundVacancies(availability) >= changeFlowInitialTicketCount,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return rows.some((availability) => (availability.vacancies ?? 0) > 0);
|
|
93
|
-
});
|
|
135
|
+
const firstWithInventory = findFirstDateWithBookableAvailability(
|
|
136
|
+
dates,
|
|
137
|
+
getRowsForDate,
|
|
138
|
+
isBookableForAutoSelection,
|
|
139
|
+
);
|
|
94
140
|
const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
|
|
95
141
|
if (!first) return;
|
|
96
142
|
|
|
@@ -107,10 +153,7 @@ export function useChangeBookingAutoSelections({
|
|
|
107
153
|
selectedDate,
|
|
108
154
|
dates,
|
|
109
155
|
getRowsForDate,
|
|
110
|
-
|
|
111
|
-
isCustomerSelfServeChange,
|
|
112
|
-
changeFlowInitialTicketCount,
|
|
113
|
-
getCalendarEffectiveOutboundVacancies,
|
|
156
|
+
isBookableForAutoSelection,
|
|
114
157
|
setSelectedDate,
|
|
115
158
|
onDateSelect,
|
|
116
159
|
suppressCalendarDateScroll,
|
|
@@ -6,6 +6,7 @@ import type { AddOn, Availability, PrecomputedPricesByCategory } from '../../lib
|
|
|
6
6
|
import type { Currency } from './CurrencySwitcher';
|
|
7
7
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
8
8
|
import {
|
|
9
|
+
deriveCompatibleInitialBookingQuantities,
|
|
9
10
|
deriveAddOnSelectionsFromReceiptLines,
|
|
10
11
|
normalizeAddOnSelections,
|
|
11
12
|
parseAvailabilityDateTime,
|
|
@@ -23,6 +24,7 @@ export interface UseChangeBookingInitialHydrationOptions {
|
|
|
23
24
|
timesForSelectedDate: Availability[];
|
|
24
25
|
companyTimezone: string;
|
|
25
26
|
changeFlowResolvedInitialProductOptionId: string | null;
|
|
27
|
+
mostPopularProductOptionId: string | null;
|
|
26
28
|
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined;
|
|
27
29
|
currency: Currency;
|
|
28
30
|
originalReceiptSubtotal?: number | null;
|
|
@@ -53,6 +55,7 @@ export function useChangeBookingInitialHydration({
|
|
|
53
55
|
timesForSelectedDate,
|
|
54
56
|
companyTimezone,
|
|
55
57
|
changeFlowResolvedInitialProductOptionId,
|
|
58
|
+
mostPopularProductOptionId,
|
|
56
59
|
precomputedPricesByOption,
|
|
57
60
|
currency,
|
|
58
61
|
originalReceiptSubtotal,
|
|
@@ -130,6 +133,7 @@ export function useChangeBookingInitialHydration({
|
|
|
130
133
|
precomputedPricesByOption,
|
|
131
134
|
currency,
|
|
132
135
|
originalReceiptSubtotal ?? undefined,
|
|
136
|
+
mostPopularProductOptionId,
|
|
133
137
|
);
|
|
134
138
|
if (defer) return;
|
|
135
139
|
if (selection) {
|
|
@@ -142,6 +146,7 @@ export function useChangeBookingInitialHydration({
|
|
|
142
146
|
initialValues?.productOptionId,
|
|
143
147
|
initialValues?.bookingItems,
|
|
144
148
|
changeFlowResolvedInitialProductOptionId,
|
|
149
|
+
mostPopularProductOptionId,
|
|
145
150
|
selectedAvailability,
|
|
146
151
|
selectedDate,
|
|
147
152
|
timesForSelectedDate,
|
|
@@ -163,15 +168,13 @@ export function useChangeBookingInitialHydration({
|
|
|
163
168
|
) {
|
|
164
169
|
return;
|
|
165
170
|
}
|
|
166
|
-
const next
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
171
|
+
const next = deriveCompatibleInitialBookingQuantities(
|
|
172
|
+
initialValues.bookingItems,
|
|
173
|
+
selectedAvailability,
|
|
174
|
+
);
|
|
175
|
+
hasAppliedInitialQuantitiesRef.current = true;
|
|
172
176
|
if (Object.keys(next).length > 0) {
|
|
173
177
|
setQuantities((prev) => ({ ...prev, ...next }));
|
|
174
|
-
hasAppliedInitialQuantitiesRef.current = true;
|
|
175
178
|
}
|
|
176
179
|
}, [initialValues, selectedAvailability, setQuantities]);
|
|
177
180
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
formatTicketLineItemsForSummary,
|
|
25
25
|
getAvailabilityOptionId,
|
|
26
26
|
} from './change-booking-flow-helpers';
|
|
27
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
27
28
|
import type { Currency } from './CurrencySwitcher';
|
|
28
29
|
|
|
29
30
|
type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
|
|
@@ -197,11 +198,12 @@ export function useChangeBookingQuotePreview({
|
|
|
197
198
|
: 'Change booking (no charge)';
|
|
198
199
|
})();
|
|
199
200
|
|
|
200
|
-
const
|
|
201
|
+
const rawCheckoutFormError =
|
|
201
202
|
(error || '') ||
|
|
202
203
|
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
203
204
|
(isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
204
205
|
(changeQuoteFetchError ?? '');
|
|
206
|
+
const checkoutFormError = friendlyChangeBookingError(rawCheckoutFormError);
|
|
205
207
|
|
|
206
208
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
207
209
|
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
@@ -30,7 +30,7 @@ export interface UseChangeBookingSelectionDetailsParams {
|
|
|
30
30
|
pickupLocationId: string | null;
|
|
31
31
|
quantities: Record<string, number>;
|
|
32
32
|
addOnSelections: AddOnSelection[];
|
|
33
|
-
|
|
33
|
+
initialAddOnQtyByKey: Map<string, number>;
|
|
34
34
|
changeFlowResolvedInitialProductOptionId: string | null;
|
|
35
35
|
productChanged?: boolean;
|
|
36
36
|
}
|
|
@@ -65,6 +65,25 @@ function normalizeCounts(items: Array<{ category: string; count: number }> | nul
|
|
|
65
65
|
return map;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function haveAddOnSelectionsChanged(
|
|
69
|
+
initialAddOnQtyByKey: Map<string, number>,
|
|
70
|
+
addOnSelections: AddOnSelection[],
|
|
71
|
+
): boolean {
|
|
72
|
+
const currentAddOnQtyByKey = new Map<string, number>();
|
|
73
|
+
for (const sel of addOnSelections) {
|
|
74
|
+
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
75
|
+
currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const allAddOnKeys = new Set([...initialAddOnQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
|
|
79
|
+
for (const key of allAddOnKeys) {
|
|
80
|
+
if ((initialAddOnQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export function useChangeBookingSelectionDetails({
|
|
69
88
|
initialValues,
|
|
70
89
|
selectedAvailability,
|
|
@@ -73,7 +92,7 @@ export function useChangeBookingSelectionDetails({
|
|
|
73
92
|
pickupLocationId,
|
|
74
93
|
quantities,
|
|
75
94
|
addOnSelections,
|
|
76
|
-
|
|
95
|
+
initialAddOnQtyByKey,
|
|
77
96
|
changeFlowResolvedInitialProductOptionId,
|
|
78
97
|
productChanged = false,
|
|
79
98
|
}: UseChangeBookingSelectionDetailsParams): ChangeBookingSelectionDetails {
|
|
@@ -115,20 +134,7 @@ export function useChangeBookingSelectionDetails({
|
|
|
115
134
|
}
|
|
116
135
|
}
|
|
117
136
|
|
|
118
|
-
const
|
|
119
|
-
for (const sel of addOnSelections) {
|
|
120
|
-
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
121
|
-
currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const allAddOnKeys = new Set([...initialAddOnMinQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
|
|
125
|
-
let addOnsChanged = false;
|
|
126
|
-
for (const key of allAddOnKeys) {
|
|
127
|
-
if ((initialAddOnMinQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
|
|
128
|
-
addOnsChanged = true;
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
137
|
+
const addOnsChanged = haveAddOnSelectionsChanged(initialAddOnQtyByKey, addOnSelections);
|
|
132
138
|
|
|
133
139
|
const hasReturnOptions = (selectedAvailability?.returnOptions?.length ?? 0) > 0;
|
|
134
140
|
const initialReturnId = initialValues.returnAvailabilityId?.trim() || null;
|
|
@@ -193,7 +199,7 @@ export function useChangeBookingSelectionDetails({
|
|
|
193
199
|
pickupLocationId,
|
|
194
200
|
quantities,
|
|
195
201
|
addOnSelections,
|
|
196
|
-
|
|
202
|
+
initialAddOnQtyByKey,
|
|
197
203
|
changeFlowResolvedInitialProductOptionId,
|
|
198
204
|
productChanged,
|
|
199
205
|
]);
|
|
@@ -3,9 +3,14 @@ import { parseISO } from 'date-fns';
|
|
|
3
3
|
import type { Availability, Product, ReturnOption } from '../../lib/booking-api';
|
|
4
4
|
import {
|
|
5
5
|
availabilityMatchesSelectedDate,
|
|
6
|
+
findMergedAvailabilityForSelection,
|
|
6
7
|
parseAvailabilityDateTime,
|
|
7
8
|
pickDefaultAvailabilityForTimes,
|
|
8
9
|
} from './standard-booking-availability';
|
|
10
|
+
import {
|
|
11
|
+
findFirstDateWithBookableAvailability,
|
|
12
|
+
selectedDateHasVisibleAvailability,
|
|
13
|
+
} from './availability-date-selection';
|
|
9
14
|
|
|
10
15
|
interface UseStandardBookingAutoSelectionsParams {
|
|
11
16
|
selectedDate: string;
|
|
@@ -91,19 +96,22 @@ export function useStandardBookingAutoSelections({
|
|
|
91
96
|
|
|
92
97
|
useEffect(() => {
|
|
93
98
|
if (!selectedDate || timesForSelectedDateLength === 0) return;
|
|
99
|
+
const eligibleTimes = getPickupCutoffEligibleTimesForDate(selectedDate);
|
|
94
100
|
if (
|
|
95
101
|
selectedAvailability &&
|
|
96
|
-
availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)
|
|
102
|
+
availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone) &&
|
|
103
|
+
findMergedAvailabilityForSelection(eligibleTimes, selectedAvailability)
|
|
97
104
|
) {
|
|
98
105
|
return;
|
|
99
106
|
}
|
|
100
|
-
const toSelect = pickDefaultAvailabilityForTimes(
|
|
101
|
-
getPickupCutoffEligibleTimesForDate(selectedDate),
|
|
102
|
-
activeOptions,
|
|
103
|
-
);
|
|
107
|
+
const toSelect = pickDefaultAvailabilityForTimes(eligibleTimes, activeOptions);
|
|
104
108
|
if (toSelect) {
|
|
105
109
|
setSelectedAvailability(toSelect);
|
|
110
|
+
setSelectedReturnOption(null);
|
|
106
111
|
setError('');
|
|
112
|
+
} else if (selectedAvailability) {
|
|
113
|
+
setSelectedAvailability(null);
|
|
114
|
+
setSelectedReturnOption(null);
|
|
107
115
|
}
|
|
108
116
|
}, [
|
|
109
117
|
selectedDate,
|
|
@@ -114,9 +122,47 @@ export function useStandardBookingAutoSelections({
|
|
|
114
122
|
companyTimezone,
|
|
115
123
|
getPickupCutoffEligibleTimesForDate,
|
|
116
124
|
setSelectedAvailability,
|
|
125
|
+
setSelectedReturnOption,
|
|
117
126
|
setError,
|
|
118
127
|
]);
|
|
119
128
|
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!autoSelectFirstAvailableDate) return;
|
|
131
|
+
if (isPartialLaunch) return;
|
|
132
|
+
if (!selectedDate || dates.length === 0) return;
|
|
133
|
+
if (selectedDateHasVisibleAvailability(selectedDate, getPickupCutoffEligibleTimesForDate)) return;
|
|
134
|
+
|
|
135
|
+
const first = findFirstDateWithBookableAvailability(
|
|
136
|
+
dates,
|
|
137
|
+
getPickupCutoffEligibleTimesForDate,
|
|
138
|
+
(availability) => (availability.vacancies ?? 0) > 0,
|
|
139
|
+
);
|
|
140
|
+
if (!first || first === selectedDate) return;
|
|
141
|
+
|
|
142
|
+
hasAutoSelectedPartnerDateRef.current = true;
|
|
143
|
+
setSelectedDate(first);
|
|
144
|
+
setSelectedReturnOption(null);
|
|
145
|
+
setSelectedAvailability(
|
|
146
|
+
pickDefaultAvailabilityForTimes(getPickupCutoffEligibleTimesForDate(first), activeOptions),
|
|
147
|
+
);
|
|
148
|
+
if (!suppressCalendarDateScroll) {
|
|
149
|
+
scrollAfterCalendarSelection(contentRef, useWindowScroll);
|
|
150
|
+
}
|
|
151
|
+
}, [
|
|
152
|
+
autoSelectFirstAvailableDate,
|
|
153
|
+
isPartialLaunch,
|
|
154
|
+
selectedDate,
|
|
155
|
+
dates,
|
|
156
|
+
getPickupCutoffEligibleTimesForDate,
|
|
157
|
+
activeOptions,
|
|
158
|
+
setSelectedAvailability,
|
|
159
|
+
setSelectedDate,
|
|
160
|
+
setSelectedReturnOption,
|
|
161
|
+
suppressCalendarDateScroll,
|
|
162
|
+
contentRef,
|
|
163
|
+
useWindowScroll,
|
|
164
|
+
]);
|
|
165
|
+
|
|
120
166
|
useEffect(() => {
|
|
121
167
|
if (!selectedAvailability?.returnOptions?.length || selectedReturnOption) return;
|
|
122
168
|
|
|
@@ -162,8 +208,10 @@ export function useStandardBookingAutoSelections({
|
|
|
162
208
|
if (dates.length === 0) return;
|
|
163
209
|
if (hasAutoSelectedPartnerDateRef.current) return;
|
|
164
210
|
|
|
165
|
-
const firstWithInventory =
|
|
166
|
-
|
|
211
|
+
const firstWithInventory = findFirstDateWithBookableAvailability(
|
|
212
|
+
dates,
|
|
213
|
+
getPickupCutoffEligibleTimesForDate,
|
|
214
|
+
(availability) => (availability.vacancies ?? 0) > 0,
|
|
167
215
|
);
|
|
168
216
|
const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
|
|
169
217
|
if (!first) return;
|