@ticketboothapp/booking 1.2.165 → 1.2.167
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 +2 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
- package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
- package/src/components/booking/NewBookingFlow.tsx +14 -2
- package/src/components/booking/PriceBreakdown.tsx +66 -15
- package/src/components/booking/PriceSummary.tsx +12 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
- package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
- package/src/components/booking/admin-change-provider-payload.ts +9 -1
- package/src/components/booking/admin-change-quote-request-key.ts +6 -6
- package/src/components/booking/admin-change-receipt-lines.ts +49 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
- package/src/components/booking/admin-refund-decision-context.ts +85 -0
- package/src/components/booking/availability-date-selection.ts +16 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
- package/src/components/booking/use-standard-booking-availability.ts +5 -2
- package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
- package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
- package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
- package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
- package/src/components/booking/useChangeBookingAutoSelections.ts +60 -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 +75 -5
- package/src/lib/booking-api.ts +12 -1
- package/test/change-booking-helpers.test.ts +466 -3
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
} from 'react';
|
|
9
9
|
import {
|
|
10
10
|
quoteChangeBooking,
|
|
11
|
-
quoteAdminChangeBookingV2,
|
|
12
11
|
type AdminAmendmentRefundDisposition,
|
|
12
|
+
type AdminPromoApplicationScope,
|
|
13
13
|
type AdminFeAuthoritativeReceipt,
|
|
14
14
|
type Availability,
|
|
15
15
|
type ItineraryDisplayStep,
|
|
@@ -31,6 +31,10 @@ import {
|
|
|
31
31
|
buildAdminChangeQuoteRequestKey,
|
|
32
32
|
canReuseCompletedAdminChangeQuote,
|
|
33
33
|
} from './admin-change-quote-request-key';
|
|
34
|
+
import {
|
|
35
|
+
buildAdminChangeV2QuoteRequest,
|
|
36
|
+
quoteAdminChangeV2ForUi,
|
|
37
|
+
} from './admin-change-v2-quote-request';
|
|
34
38
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
35
39
|
|
|
36
40
|
type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
|
|
@@ -63,6 +67,8 @@ export interface UseAdminChangeQuotePreviewParams {
|
|
|
63
67
|
quantities: Record<string, number>;
|
|
64
68
|
addOnSelections: AddOnSelection[];
|
|
65
69
|
cancellationPolicyId: string | null;
|
|
70
|
+
appliedPromoCode: string | null;
|
|
71
|
+
promoApplicationScope: AdminPromoApplicationScope;
|
|
66
72
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
67
73
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
68
74
|
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
@@ -122,6 +128,8 @@ export function useAdminChangeQuotePreview({
|
|
|
122
128
|
quantities,
|
|
123
129
|
addOnSelections,
|
|
124
130
|
cancellationPolicyId,
|
|
131
|
+
appliedPromoCode,
|
|
132
|
+
promoApplicationScope,
|
|
125
133
|
adminCustomReceiptLines,
|
|
126
134
|
adminStructuredAdjustments,
|
|
127
135
|
refundDispositionsByOperationId,
|
|
@@ -182,6 +190,8 @@ export function useAdminChangeQuotePreview({
|
|
|
182
190
|
addOnSelections,
|
|
183
191
|
adminCustomReceiptLines,
|
|
184
192
|
cancellationPolicyId,
|
|
193
|
+
promoCode: appliedPromoCode,
|
|
194
|
+
promoApplicationScope,
|
|
185
195
|
structuredAdjustments: adminStructuredAdjustments,
|
|
186
196
|
refundDispositionsByOperationId,
|
|
187
197
|
useAdminFeAuthoritativeQuote,
|
|
@@ -196,6 +206,8 @@ export function useAdminChangeQuotePreview({
|
|
|
196
206
|
addOnSelections,
|
|
197
207
|
adminCustomReceiptLines,
|
|
198
208
|
cancellationPolicyId,
|
|
209
|
+
appliedPromoCode,
|
|
210
|
+
promoApplicationScope,
|
|
199
211
|
adminStructuredAdjustments,
|
|
200
212
|
refundDispositionsByOperationId,
|
|
201
213
|
useAdminFeAuthoritativeQuote,
|
|
@@ -445,44 +457,44 @@ export function useAdminChangeQuotePreview({
|
|
|
445
457
|
.filter(([, count]) => count > 0)
|
|
446
458
|
.map(([category, count]) => ({ category, count }));
|
|
447
459
|
try {
|
|
448
|
-
const quoteRequestBase = {
|
|
460
|
+
const quoteRequestBase = buildAdminChangeV2QuoteRequest({
|
|
449
461
|
bookingReference: bookingReferenceForQuote,
|
|
450
|
-
lastName
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
462
|
+
lastName,
|
|
463
|
+
parentProductId: product.productId,
|
|
464
|
+
optionId,
|
|
465
|
+
selectedAvailability,
|
|
466
|
+
pickupLocationId,
|
|
467
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
468
|
+
bookingItems,
|
|
469
|
+
addOnSelections,
|
|
470
|
+
cancellationPolicyId: cancellationPolicyId ?? null,
|
|
471
|
+
promoCode: appliedPromoCode,
|
|
472
|
+
promoApplicationScope,
|
|
461
473
|
structuredAdjustments: adminStructuredAdjustments,
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
474
|
+
refundDispositionsByOperationId,
|
|
475
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
476
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
477
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
478
|
+
});
|
|
479
|
+
const mergedQuote = useAdminFeAuthoritativeQuote
|
|
480
|
+
? await quoteAdminChangeV2ForUi(
|
|
481
|
+
quoteRequestBase,
|
|
482
|
+
{
|
|
483
|
+
total: changeFlowNewBookingTotal,
|
|
484
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
485
|
+
tax: effectiveTax,
|
|
486
|
+
},
|
|
487
|
+
currency,
|
|
488
|
+
)
|
|
489
|
+
: sliceAndMergeChangeQuoteForUi(await quoteChangeBooking({
|
|
475
490
|
...quoteRequestBase,
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
491
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
492
|
+
}), {
|
|
493
|
+
total: changeFlowNewBookingTotal,
|
|
494
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
495
|
+
tax: effectiveTax,
|
|
496
|
+
}, currency).mergedQuote;
|
|
480
497
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
481
|
-
const mergedQuote = sliceAndMergeChangeQuoteForUi(quote, {
|
|
482
|
-
total: changeFlowNewBookingTotal,
|
|
483
|
-
subtotal: effectiveSubtotalForCheckout,
|
|
484
|
-
tax: effectiveTax,
|
|
485
|
-
}, currency).mergedQuote;
|
|
486
498
|
// Commit the completed identity before publishing its result. This keeps a
|
|
487
499
|
// render caused by setLatestChangeQuote from being interpreted as a new request.
|
|
488
500
|
lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
|
|
@@ -509,8 +521,6 @@ export function useAdminChangeQuotePreview({
|
|
|
509
521
|
}, [
|
|
510
522
|
// Fetching is controlled by the canonical user-input identity. Do not add quote-derived
|
|
511
523
|
// totals, receipt objects, or display lines here: a response must not trigger another quote.
|
|
512
|
-
// Quote presence is intentionally included so a cleared transient quote can be restored by
|
|
513
|
-
// refetching the same canonical input identity after a nested checkout is closed.
|
|
514
524
|
isCustomerSelfServeChange,
|
|
515
525
|
hasChangeSelection,
|
|
516
526
|
missingRequiredReturnSelection,
|
|
@@ -29,6 +29,7 @@ export function useBookingAvailabilityAddOns({
|
|
|
29
29
|
() => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null),
|
|
30
30
|
[selectedAvailability],
|
|
31
31
|
);
|
|
32
|
+
const availabilityDateTime = selectedAvailability?.dateTime?.trim() || null;
|
|
32
33
|
|
|
33
34
|
useEffect(() => {
|
|
34
35
|
if (!availabilityProductOptionId || !companyId) {
|
|
@@ -41,10 +42,14 @@ export function useBookingAvailabilityAddOns({
|
|
|
41
42
|
onProductOptionChange?.();
|
|
42
43
|
previousAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
|
|
43
44
|
}
|
|
44
|
-
getAddOns(companyId, {
|
|
45
|
+
getAddOns(companyId, {
|
|
46
|
+
productOptionId: availabilityProductOptionId,
|
|
47
|
+
preCheckout: true,
|
|
48
|
+
dateTime: availabilityDateTime ?? undefined,
|
|
49
|
+
})
|
|
45
50
|
.then(setAddOns)
|
|
46
51
|
.catch(() => setAddOns([]));
|
|
47
|
-
}, [availabilityProductOptionId, companyId, onProductOptionChange]);
|
|
52
|
+
}, [availabilityProductOptionId, availabilityDateTime, companyId, onProductOptionChange]);
|
|
48
53
|
|
|
49
54
|
return {
|
|
50
55
|
addOns,
|
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -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;
|
|
@@ -55,6 +55,28 @@ export function filterAvailabilitiesAfterPublicBookingCutoff<T extends { dateTim
|
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Admin surfaces intentionally ignore the public advance-booking cutoff, but an
|
|
60
|
+
* availability whose actual start time has passed is no longer bookable by the
|
|
61
|
+
* API. Keep that narrower rule separate so admin and public behavior cannot
|
|
62
|
+
* accidentally inherit one another's cutoff policy.
|
|
63
|
+
*/
|
|
64
|
+
export function isAvailabilityAfterCurrentTime(
|
|
65
|
+
availability: { dateTime: string },
|
|
66
|
+
now: Date = new Date(),
|
|
67
|
+
): boolean {
|
|
68
|
+
return isAvailabilityAfterPublicBookingCutoff(availability, now, 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function filterAvailabilitiesAfterCurrentTime<T extends { dateTime: string }>(
|
|
72
|
+
availabilities: T[],
|
|
73
|
+
now: Date = new Date(),
|
|
74
|
+
): T[] {
|
|
75
|
+
return availabilities.filter((availability) =>
|
|
76
|
+
isAvailabilityAfterCurrentTime(availability, now),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
export function isDateWithinPublicPickupUnknownCutoff(date: Date, now: Date = new Date()): boolean {
|
|
59
81
|
return date.getTime() <= now.getTime() + PUBLIC_PICKUP_UNKNOWN_CUTOFF_MS;
|
|
60
82
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* See `CHANGE_BOOKING_BE_HANDOFF.md` in this package for fields the API should populate.
|
|
6
6
|
*/
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AdminAmendmentOperationSnapshot,
|
|
9
|
+
ChangeBookingQuoteResponse,
|
|
10
|
+
} from '../booking-api';
|
|
8
11
|
import type { PricingV2QuoteLineSnapshot } from '../booking-api';
|
|
9
12
|
import type { PriceBasisSnapshot } from '../booking-api';
|
|
10
13
|
import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
|
|
@@ -207,6 +210,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
207
210
|
: type;
|
|
208
211
|
out.push({
|
|
209
212
|
kind: 'ticket',
|
|
213
|
+
operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
|
|
210
214
|
category,
|
|
211
215
|
qty: qty > 0 ? qty : 1,
|
|
212
216
|
itemTotal: amount,
|
|
@@ -228,6 +232,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
228
232
|
) {
|
|
229
233
|
out.push({
|
|
230
234
|
kind: 'ticket',
|
|
235
|
+
operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
|
|
231
236
|
category: lettersOnly,
|
|
232
237
|
qty,
|
|
233
238
|
itemTotal: amount,
|
|
@@ -249,6 +254,64 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
249
254
|
return out;
|
|
250
255
|
}
|
|
251
256
|
|
|
257
|
+
/** Clarify ticket deltas caused by moving a booking without changing receipt semantics. */
|
|
258
|
+
export function labelAdminAmendmentPriceSummaryLines(
|
|
259
|
+
lines: PriceSummaryLine[],
|
|
260
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
261
|
+
originalLines: readonly PriceSummaryLine[] = [],
|
|
262
|
+
): PriceSummaryLine[] {
|
|
263
|
+
const movedServiceOperationIds = new Set(
|
|
264
|
+
operations
|
|
265
|
+
.filter((operation) => operation.type.trim().toUpperCase() === 'MOVE_SERVICE')
|
|
266
|
+
.map((operation) => operation.operationId),
|
|
267
|
+
);
|
|
268
|
+
if (movedServiceOperationIds.size === 0) return lines;
|
|
269
|
+
|
|
270
|
+
const normalizedTicketCategory = (category: string) => category
|
|
271
|
+
.replace(/^refund\s*[·:-]\s*/i, '')
|
|
272
|
+
.replace(/\s*[—-]\s*new date price difference\s*$/i, '')
|
|
273
|
+
.trim()
|
|
274
|
+
.toUpperCase();
|
|
275
|
+
|
|
276
|
+
return lines.map((line) => {
|
|
277
|
+
if (
|
|
278
|
+
line.kind !== 'ticket' ||
|
|
279
|
+
!line.operationId ||
|
|
280
|
+
!movedServiceOperationIds.has(line.operationId) ||
|
|
281
|
+
line.category.toLowerCase().includes('new date price difference')
|
|
282
|
+
) {
|
|
283
|
+
return line;
|
|
284
|
+
}
|
|
285
|
+
const originalLine = originalLines.find(
|
|
286
|
+
(candidate) => candidate.kind === 'ticket' &&
|
|
287
|
+
normalizedTicketCategory(candidate.category) === normalizedTicketCategory(line.category),
|
|
288
|
+
);
|
|
289
|
+
const previousUnitAmount = originalLine?.kind === 'ticket' && originalLine.qty > 0
|
|
290
|
+
? roundMoney(originalLine.itemTotal / originalLine.qty)
|
|
291
|
+
: null;
|
|
292
|
+
const differenceUnitAmount = line.qty > 0
|
|
293
|
+
? roundMoney(line.itemTotal / line.qty)
|
|
294
|
+
: null;
|
|
295
|
+
const ticketLabel = normalizedTicketCategory(line.category).toLowerCase();
|
|
296
|
+
return {
|
|
297
|
+
...line,
|
|
298
|
+
category: `${line.category} — new date price difference`,
|
|
299
|
+
...(previousUnitAmount != null && differenceUnitAmount != null
|
|
300
|
+
? {
|
|
301
|
+
unitPriceComparison: {
|
|
302
|
+
previousLabel: `Existing ${ticketLabel} price`,
|
|
303
|
+
updatedLabel: `New-date ${ticketLabel} price`,
|
|
304
|
+
differenceLabel: `Difference per ${ticketLabel}`,
|
|
305
|
+
previousUnitAmount,
|
|
306
|
+
updatedUnitAmount: roundMoney(previousUnitAmount + differenceUnitAmount),
|
|
307
|
+
differenceUnitAmount,
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: {}),
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
252
315
|
function signedAmountDueFromQuote(quote: ChangeBookingQuoteResponse): number {
|
|
253
316
|
const pricingQuote = quote.pricingQuote ?? quote.quote;
|
|
254
317
|
// Prefer explicit settlement fields over balanceDelta. Receipt-total math and balanceDelta
|
|
@@ -280,10 +343,17 @@ export function buildChangeBookingServerPreview(
|
|
|
280
343
|
const paymentCreditTotal = pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal;
|
|
281
344
|
|
|
282
345
|
const rawLines = lineItemsForSummary(quote);
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
346
|
+
const mappedLines = rawLines && rawLines.length > 0
|
|
347
|
+
? mapQuoteLineItemsToPriceSummaryLines(rawLines)
|
|
348
|
+
: [];
|
|
349
|
+
const priceSummaryLines = withPaymentCreditSummaryLine(
|
|
350
|
+
labelAdminAmendmentPriceSummaryLines(
|
|
351
|
+
mappedLines,
|
|
352
|
+
pricingQuote?.amendment?.operations ?? [],
|
|
353
|
+
mapQuoteLineItemsToPriceSummaryLines(quote.originalReceipt?.lineItems),
|
|
354
|
+
),
|
|
355
|
+
paymentCreditTotal,
|
|
356
|
+
);
|
|
287
357
|
|
|
288
358
|
const completeness: ChangeBookingPreviewCompleteness = priceSummaryLines.length > 0 ? 'full' : 'totals_only';
|
|
289
359
|
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -1102,7 +1102,7 @@ export async function getPromoDiscount(
|
|
|
1102
1102
|
|
|
1103
1103
|
export async function getAddOns(
|
|
1104
1104
|
companyId: string,
|
|
1105
|
-
options?: { productOptionId?: string; preCheckout?: boolean }
|
|
1105
|
+
options?: { productOptionId?: string; preCheckout?: boolean; dateTime?: string }
|
|
1106
1106
|
): Promise<AddOn[]> {
|
|
1107
1107
|
const params = new URLSearchParams({ companyId });
|
|
1108
1108
|
if (options?.productOptionId) {
|
|
@@ -1110,6 +1110,7 @@ export async function getAddOns(
|
|
|
1110
1110
|
if (po) params.set('productOptionId', po);
|
|
1111
1111
|
}
|
|
1112
1112
|
if (options?.preCheckout !== undefined) params.set('preCheckout', String(options.preCheckout));
|
|
1113
|
+
if (options?.dateTime?.trim()) params.set('dateTime', options.dateTime.trim());
|
|
1113
1114
|
const res = await fetchBookingGetWithRetry(`${API_BASE}/1/add-ons?${params}`);
|
|
1114
1115
|
if (!res.ok) {
|
|
1115
1116
|
const err = await res.json();
|
|
@@ -1251,6 +1252,8 @@ export type AdminAmendmentRefundDisposition =
|
|
|
1251
1252
|
| 'PENDING_REFUND'
|
|
1252
1253
|
| 'NO_REFUND';
|
|
1253
1254
|
|
|
1255
|
+
export type AdminPromoApplicationScope = 'CHANGE_ONLY' | 'ENTIRE_BOOKING';
|
|
1256
|
+
|
|
1254
1257
|
export interface AdminAmendmentOperationSnapshot {
|
|
1255
1258
|
operationId: string;
|
|
1256
1259
|
type: string;
|
|
@@ -1276,6 +1279,9 @@ export interface ChangeBookingQuoteRequest {
|
|
|
1276
1279
|
newReturnAvailabilityId?: string | null;
|
|
1277
1280
|
newPassengerCounts?: Array<{ category: string; count: number }>;
|
|
1278
1281
|
newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
1282
|
+
/** Explicit promo intent for this amendment. Historical booking promos are never implied. */
|
|
1283
|
+
promoCode?: string | null;
|
|
1284
|
+
promoApplicationScope?: AdminPromoApplicationScope | null;
|
|
1279
1285
|
/** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
|
|
1280
1286
|
clientProposedTotal?: number;
|
|
1281
1287
|
/**
|
|
@@ -1354,6 +1360,7 @@ export interface ChangeBookingQuoteReceipt {
|
|
|
1354
1360
|
amount?: number;
|
|
1355
1361
|
type?: string;
|
|
1356
1362
|
quantity?: number;
|
|
1363
|
+
priceBasis?: PriceBasisSnapshot | null;
|
|
1357
1364
|
}>;
|
|
1358
1365
|
}
|
|
1359
1366
|
|
|
@@ -1704,6 +1711,7 @@ export function mapAdminChangeBookingQuoteV2Data(
|
|
|
1704
1711
|
amount: line.amount ?? undefined,
|
|
1705
1712
|
type: line.type ?? undefined,
|
|
1706
1713
|
quantity: line.quantity ?? undefined,
|
|
1714
|
+
priceBasis: line.priceBasis ?? undefined,
|
|
1707
1715
|
})),
|
|
1708
1716
|
},
|
|
1709
1717
|
};
|
|
@@ -2239,6 +2247,9 @@ export interface PricingV2QuoteSnapshot {
|
|
|
2239
2247
|
bookingReference?: string | null;
|
|
2240
2248
|
reservationReference?: string | null;
|
|
2241
2249
|
relatedChangeIntentId?: string | null;
|
|
2250
|
+
amendment?: {
|
|
2251
|
+
operations?: AdminAmendmentOperationSnapshot[] | null;
|
|
2252
|
+
} | null;
|
|
2242
2253
|
metadata?: Record<string, string> | null;
|
|
2243
2254
|
[key: string]: unknown;
|
|
2244
2255
|
}
|