@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
|
@@ -32,9 +32,11 @@ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection
|
|
|
32
32
|
import {
|
|
33
33
|
getPrivateShuttleAvailabilityOptionId,
|
|
34
34
|
privateShuttleBookingCutoffMessage,
|
|
35
|
+
privateShuttleStartDateTime,
|
|
35
36
|
privateShuttleStartTimeAllowed,
|
|
36
37
|
resolveHydratedPrivateShuttleAvailability,
|
|
37
38
|
} from './private-shuttle-availability';
|
|
39
|
+
import { formatInTimeZone } from 'date-fns-tz';
|
|
38
40
|
import { usePrivateShuttleAvailability } from './use-private-shuttle-availability';
|
|
39
41
|
import type {
|
|
40
42
|
ProviderDashboardChangeBookingPayload,
|
|
@@ -285,13 +287,35 @@ export function PrivateShuttleBookingFlow({
|
|
|
285
287
|
setAddOns([]);
|
|
286
288
|
return;
|
|
287
289
|
}
|
|
290
|
+
const dateTimeForAddOns =
|
|
291
|
+
selectedDate && selectedStartTime
|
|
292
|
+
? formatInTimeZone(
|
|
293
|
+
privateShuttleStartDateTime(selectedDate, selectedStartTime, companyTimezone),
|
|
294
|
+
companyTimezone,
|
|
295
|
+
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
|
296
|
+
)
|
|
297
|
+
: selectedDate || undefined;
|
|
288
298
|
getAddOns(product.companyId!, {
|
|
289
299
|
productOptionId: selectedOption,
|
|
290
300
|
preCheckout: true,
|
|
301
|
+
dateTime: dateTimeForAddOns,
|
|
291
302
|
})
|
|
292
303
|
.then(setAddOns)
|
|
293
304
|
.catch(() => setAddOns([]));
|
|
294
|
-
}, [selectedOption, product.companyId]);
|
|
305
|
+
}, [selectedOption, selectedDate, selectedStartTime, product.companyId, companyTimezone]);
|
|
306
|
+
|
|
307
|
+
const previousAddOnIdsRef = useRef<Set<string>>(new Set());
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const availableIds = new Set(addOns.map((a) => a.addOnId));
|
|
310
|
+
const previousIds = previousAddOnIdsRef.current;
|
|
311
|
+
if (previousIds.size > 0) {
|
|
312
|
+
setAddOnSelections((prev) => {
|
|
313
|
+
const next = prev.filter((s) => !previousIds.has(s.addOnId) || availableIds.has(s.addOnId));
|
|
314
|
+
return next.length === prev.length ? prev : next;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
previousAddOnIdsRef.current = availableIds;
|
|
318
|
+
}, [addOns]);
|
|
295
319
|
|
|
296
320
|
useEffect(() => {
|
|
297
321
|
if (!draftItineraryDestinations.includes('emerald_lake')) {
|
|
@@ -126,6 +126,18 @@ export function buildAdminChangePayNowCheckoutModalData(
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export function shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
|
|
130
|
+
isProviderDashboardChange: boolean,
|
|
131
|
+
isChangeBookingContext: boolean,
|
|
132
|
+
adminChoiceData: Pick<AdminChangePaymentChoiceData, 'pricingQuoteId'> | null,
|
|
133
|
+
): boolean {
|
|
134
|
+
return Boolean(
|
|
135
|
+
isProviderDashboardChange &&
|
|
136
|
+
isChangeBookingContext &&
|
|
137
|
+
adminChoiceData?.pricingQuoteId?.trim(),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
129
141
|
export interface ConfirmAdminChangeBookingWithoutPaymentParams {
|
|
130
142
|
adminChoiceData: AdminChangePaymentChoiceData;
|
|
131
143
|
bookingSourceAttribution: Partial<BookingSourceMetadata>;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AdminPromoApplicationScope,
|
|
3
|
+
Availability,
|
|
4
|
+
Product,
|
|
5
|
+
ReturnOption,
|
|
6
|
+
} from '../../lib/booking-api';
|
|
2
7
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
3
8
|
|
|
4
9
|
export type AdminChangeBookingItem = { category: string; count: number };
|
|
@@ -29,6 +34,7 @@ export interface BuildAdminChangeProviderPayloadParams {
|
|
|
29
34
|
cancellationPolicyId: string | null;
|
|
30
35
|
initialCancellationPolicyId?: string | null;
|
|
31
36
|
appliedPromoCode: string | null;
|
|
37
|
+
promoApplicationScope?: AdminPromoApplicationScope;
|
|
32
38
|
newTotalAmount: number;
|
|
33
39
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
34
40
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
@@ -56,6 +62,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
56
62
|
cancellationPolicyId,
|
|
57
63
|
initialCancellationPolicyId,
|
|
58
64
|
appliedPromoCode,
|
|
65
|
+
promoApplicationScope = 'CHANGE_ONLY',
|
|
59
66
|
newTotalAmount,
|
|
60
67
|
providerPricingOverrides,
|
|
61
68
|
mergedProviderAdditionalAdjustments,
|
|
@@ -92,6 +99,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
92
99
|
addOnSelections,
|
|
93
100
|
cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
|
|
94
101
|
promoCode: appliedPromoCode ?? null,
|
|
102
|
+
promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
|
|
95
103
|
newTotalAmount,
|
|
96
104
|
additionalHoursCount: null,
|
|
97
105
|
pricingAdjustment:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Availability } from '../../lib/booking-api';
|
|
1
|
+
import type { AdminPromoApplicationScope, Availability } from '../../lib/booking-api';
|
|
2
2
|
import { getAvailabilityOptionId } from './change-booking-flow-helpers';
|
|
3
3
|
|
|
4
4
|
interface AdminChangeQuoteRequestKeyInput {
|
|
@@ -12,6 +12,8 @@ interface AdminChangeQuoteRequestKeyInput {
|
|
|
12
12
|
addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
13
13
|
adminCustomReceiptLines: unknown[];
|
|
14
14
|
cancellationPolicyId?: string | null;
|
|
15
|
+
promoCode?: string | null;
|
|
16
|
+
promoApplicationScope?: AdminPromoApplicationScope | null;
|
|
15
17
|
structuredAdjustments?: unknown[];
|
|
16
18
|
refundDispositionsByOperationId?: Record<string, string>;
|
|
17
19
|
useAdminFeAuthoritativeQuote: boolean;
|
|
@@ -36,6 +38,8 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
36
38
|
addOnSelections: input.addOnSelections,
|
|
37
39
|
adminCustomReceiptLines: input.adminCustomReceiptLines,
|
|
38
40
|
cancellationPolicyId: input.cancellationPolicyId ?? null,
|
|
41
|
+
promoCode: input.promoCode?.trim().toUpperCase() ?? null,
|
|
42
|
+
promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
|
|
39
43
|
structuredAdjustments: input.structuredAdjustments ?? [],
|
|
40
44
|
refundDispositionsByOperationId: Object.fromEntries(
|
|
41
45
|
Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
@@ -44,11 +48,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
/**
|
|
48
|
-
* A completed request is reusable only while its authoritative quote is still published.
|
|
49
|
-
* Closing a nested payment dialog can clear transient quote state without changing the
|
|
50
|
-
* selection inputs; in that case the same input key must be eligible for a refetch.
|
|
51
|
-
*/
|
|
51
|
+
/** A completed request is reusable only while its quote is still published in UI state. */
|
|
52
52
|
export function canReuseCompletedAdminChangeQuote(
|
|
53
53
|
inputKey: string,
|
|
54
54
|
lastCompletedInputKey: string | null,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PriceSummaryLine } from './PriceSummary';
|
|
2
|
+
|
|
3
|
+
function isSelectedPromoLine(line: PriceSummaryLine, promoCode: string): boolean {
|
|
4
|
+
if (line.kind !== 'line') return false;
|
|
5
|
+
const type = String(line.type ?? '').trim().toUpperCase();
|
|
6
|
+
if (type === 'PROMO_CODE') return true;
|
|
7
|
+
if (type !== 'DISCOUNT') return false;
|
|
8
|
+
|
|
9
|
+
const label = line.label.trim().toUpperCase();
|
|
10
|
+
return label.startsWith('DISCOUNT ·') ||
|
|
11
|
+
label.startsWith('PROMO ') ||
|
|
12
|
+
label.includes(promoCode);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keep component-level promo allocations in the server quote/ledger while presenting
|
|
17
|
+
* the selected promo as one customer-facing row in the admin amendment summary.
|
|
18
|
+
*/
|
|
19
|
+
export function collapseAdminPromoLines(
|
|
20
|
+
lines: PriceSummaryLine[],
|
|
21
|
+
promoCode: string | null | undefined,
|
|
22
|
+
): PriceSummaryLine[] {
|
|
23
|
+
const normalizedCode = promoCode?.trim().toUpperCase() ?? '';
|
|
24
|
+
if (!normalizedCode) return lines;
|
|
25
|
+
|
|
26
|
+
const promoIndexes = lines
|
|
27
|
+
.map((line, index) => isSelectedPromoLine(line, normalizedCode) ? index : -1)
|
|
28
|
+
.filter((index) => index >= 0);
|
|
29
|
+
if (promoIndexes.length === 0) return lines;
|
|
30
|
+
|
|
31
|
+
const promoIndexSet = new Set(promoIndexes);
|
|
32
|
+
const firstPromoIndex = promoIndexes[0];
|
|
33
|
+
const promoAmount = Math.round(promoIndexes.reduce((sum, index) => {
|
|
34
|
+
const line = lines[index];
|
|
35
|
+
return sum + (line.kind === 'line' ? line.amount : 0);
|
|
36
|
+
}, 0) * 100) / 100;
|
|
37
|
+
|
|
38
|
+
return lines.flatMap((line, index) => {
|
|
39
|
+
if (index === firstPromoIndex) {
|
|
40
|
+
return [{
|
|
41
|
+
kind: 'line' as const,
|
|
42
|
+
label: `Promo: ${normalizedCode}`,
|
|
43
|
+
amount: promoAmount,
|
|
44
|
+
type: 'PROMO_CODE',
|
|
45
|
+
}];
|
|
46
|
+
}
|
|
47
|
+
return promoIndexSet.has(index) ? [] : [line];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminAmendmentRefundDisposition,
|
|
3
|
+
AdminPromoApplicationScope,
|
|
4
|
+
Availability,
|
|
5
|
+
ChangeBookingQuoteRequest,
|
|
6
|
+
ChangeBookingQuoteResponse,
|
|
7
|
+
} from '../../lib/booking-api';
|
|
8
|
+
import { quoteAdminChangeBookingV2 } from '../../lib/booking-api';
|
|
9
|
+
import type { Currency } from './CurrencySwitcher';
|
|
10
|
+
import {
|
|
11
|
+
sliceAndMergeChangeQuoteForUi,
|
|
12
|
+
type ChangeBookingMergedQuoteState,
|
|
13
|
+
} from './change-booking-quote-state';
|
|
14
|
+
|
|
15
|
+
export type AdminChangeV2QuoteRequest = ChangeBookingQuoteRequest & {
|
|
16
|
+
cancellationPolicyId?: string | null;
|
|
17
|
+
structuredAdjustments?: unknown[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface BuildAdminChangeV2QuoteRequestParams {
|
|
21
|
+
bookingReference: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
parentProductId: string;
|
|
24
|
+
optionId: string;
|
|
25
|
+
selectedAvailability: Availability;
|
|
26
|
+
pickupLocationId: string | null;
|
|
27
|
+
returnAvailabilityId: string | null;
|
|
28
|
+
bookingItems: Array<{ category: string; count: number }>;
|
|
29
|
+
addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
30
|
+
cancellationPolicyId: string | null;
|
|
31
|
+
promoCode?: string | null;
|
|
32
|
+
promoApplicationScope?: AdminPromoApplicationScope;
|
|
33
|
+
structuredAdjustments: unknown[];
|
|
34
|
+
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
35
|
+
previousPassengerCount: number;
|
|
36
|
+
previousAvailabilityId: string | null;
|
|
37
|
+
previousReturnAvailabilityId: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Canonical server-authoritative provider/admin quote request.
|
|
42
|
+
* Preview and checkout must use the same endpoint and request shape so checkout cannot
|
|
43
|
+
* replace a valid Pricing V2 quote with a legacy response that has no quote id.
|
|
44
|
+
*/
|
|
45
|
+
export function buildAdminChangeV2QuoteRequest({
|
|
46
|
+
bookingReference,
|
|
47
|
+
lastName,
|
|
48
|
+
parentProductId,
|
|
49
|
+
optionId,
|
|
50
|
+
selectedAvailability,
|
|
51
|
+
pickupLocationId,
|
|
52
|
+
returnAvailabilityId,
|
|
53
|
+
bookingItems,
|
|
54
|
+
addOnSelections,
|
|
55
|
+
cancellationPolicyId,
|
|
56
|
+
promoCode,
|
|
57
|
+
promoApplicationScope = 'CHANGE_ONLY',
|
|
58
|
+
structuredAdjustments,
|
|
59
|
+
refundDispositionsByOperationId,
|
|
60
|
+
previousPassengerCount,
|
|
61
|
+
previousAvailabilityId,
|
|
62
|
+
previousReturnAvailabilityId,
|
|
63
|
+
}: BuildAdminChangeV2QuoteRequestParams): AdminChangeV2QuoteRequest {
|
|
64
|
+
return {
|
|
65
|
+
bookingReference: bookingReference.trim(),
|
|
66
|
+
lastName: lastName.trim(),
|
|
67
|
+
newProductId: optionId,
|
|
68
|
+
newParentProductId: parentProductId,
|
|
69
|
+
newDateTime: selectedAvailability.dateTime,
|
|
70
|
+
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
71
|
+
newPickupLocationId: pickupLocationId || null,
|
|
72
|
+
newReturnAvailabilityId: returnAvailabilityId,
|
|
73
|
+
newPassengerCounts: bookingItems,
|
|
74
|
+
// Admin owns the complete target selection. [] intentionally clears all add-ons.
|
|
75
|
+
newAddOnSelections: addOnSelections,
|
|
76
|
+
cancellationPolicyId,
|
|
77
|
+
promoCode: promoCode?.trim() || null,
|
|
78
|
+
promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
|
|
79
|
+
structuredAdjustments,
|
|
80
|
+
...(Object.keys(refundDispositionsByOperationId).length > 0
|
|
81
|
+
? { refundDispositionsByOperationId }
|
|
82
|
+
: {}),
|
|
83
|
+
capacitySeatCredit: {
|
|
84
|
+
enabled: true,
|
|
85
|
+
previousPassengerCount,
|
|
86
|
+
previousAvailabilityId,
|
|
87
|
+
previousReturnAvailabilityId,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function quoteAdminChangeV2ForUi(
|
|
93
|
+
request: AdminChangeV2QuoteRequest,
|
|
94
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
95
|
+
currency: Currency,
|
|
96
|
+
quoteFn: (request: ChangeBookingQuoteRequest) => Promise<ChangeBookingQuoteResponse> =
|
|
97
|
+
quoteAdminChangeBookingV2,
|
|
98
|
+
): Promise<ChangeBookingMergedQuoteState> {
|
|
99
|
+
const quote = await quoteFn(request);
|
|
100
|
+
return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
|
|
101
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminAmendmentOperationSnapshot,
|
|
3
|
+
AdminAmendmentRefundDisposition,
|
|
4
|
+
} from '../../lib/booking-api';
|
|
5
|
+
|
|
6
|
+
export interface AdminRefundDecisionContext {
|
|
7
|
+
selectionKey: string;
|
|
8
|
+
operations: AdminAmendmentOperationSnapshot[];
|
|
9
|
+
allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
10
|
+
removedValueByOperationId: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MergeAdminRefundDecisionContextParams {
|
|
14
|
+
current: AdminRefundDecisionContext | null;
|
|
15
|
+
selectionKey: string;
|
|
16
|
+
operations: AdminAmendmentOperationSnapshot[];
|
|
17
|
+
allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
18
|
+
removedValueByOperationId: Record<string, number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Refund-decision quote responses are incremental: after one operation is answered,
|
|
23
|
+
* the server can return only the operations that still need an answer. Keep all of
|
|
24
|
+
* the operations for the current booking selection so an answered operation does
|
|
25
|
+
* not disappear from the next request and get requested again.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeAdminRefundDecisionContext({
|
|
28
|
+
current,
|
|
29
|
+
selectionKey,
|
|
30
|
+
operations,
|
|
31
|
+
allowedByOperationId,
|
|
32
|
+
removedValueByOperationId,
|
|
33
|
+
}: MergeAdminRefundDecisionContextParams): AdminRefundDecisionContext | null {
|
|
34
|
+
const sameSelection = current?.selectionKey === selectionKey ? current : null;
|
|
35
|
+
if (!sameSelection && operations.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
const operationsById = new Map<string, AdminAmendmentOperationSnapshot>();
|
|
38
|
+
for (const operation of sameSelection?.operations ?? []) {
|
|
39
|
+
operationsById.set(operation.operationId, operation);
|
|
40
|
+
}
|
|
41
|
+
for (const operation of operations) {
|
|
42
|
+
operationsById.set(operation.operationId, operation);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const relevantOperationIds = new Set(operationsById.keys());
|
|
46
|
+
const mergedAllowed = {
|
|
47
|
+
...(sameSelection?.allowedByOperationId ?? {}),
|
|
48
|
+
...allowedByOperationId,
|
|
49
|
+
};
|
|
50
|
+
const mergedRemovedValues = {
|
|
51
|
+
...(sameSelection?.removedValueByOperationId ?? {}),
|
|
52
|
+
...removedValueByOperationId,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
selectionKey,
|
|
57
|
+
operations: [...operationsById.values()],
|
|
58
|
+
allowedByOperationId: Object.fromEntries(
|
|
59
|
+
Object.entries(mergedAllowed).filter(([operationId]) =>
|
|
60
|
+
relevantOperationIds.has(operationId),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
removedValueByOperationId: Object.fromEntries(
|
|
64
|
+
Object.entries(mergedRemovedValues).filter(([operationId]) =>
|
|
65
|
+
relevantOperationIds.has(operationId),
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function selectRefundDispositionsForOperations(
|
|
72
|
+
context: AdminRefundDecisionContext | null,
|
|
73
|
+
additionalOperations: AdminAmendmentOperationSnapshot[],
|
|
74
|
+
dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
|
|
75
|
+
): Record<string, AdminAmendmentRefundDisposition> {
|
|
76
|
+
const relevantOperationIds = new Set([
|
|
77
|
+
...(context?.operations ?? []).map((operation) => operation.operationId),
|
|
78
|
+
...additionalOperations.map((operation) => operation.operationId),
|
|
79
|
+
]);
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(dispositionsByOperationId).filter(([operationId]) =>
|
|
82
|
+
relevantOperationIds.has(operationId),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Availability } from '../../lib/booking-api';
|
|
2
|
+
|
|
3
|
+
export function findFirstDateWithBookableAvailability(
|
|
4
|
+
dates: string[],
|
|
5
|
+
getRowsForDate: (date: string) => Availability[],
|
|
6
|
+
isBookable: (availability: Availability) => boolean,
|
|
7
|
+
): string | undefined {
|
|
8
|
+
return dates.find((date) => getRowsForDate(date).some(isBookable));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function selectedDateHasVisibleAvailability(
|
|
12
|
+
selectedDate: string,
|
|
13
|
+
getRowsForDate: (date: string) => Availability[],
|
|
14
|
+
): boolean {
|
|
15
|
+
return selectedDate !== '' && getRowsForDate(selectedDate).length > 0;
|
|
16
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Currency } from './CurrencySwitcher';
|
|
2
|
-
import type { ItineraryDisplayStep } from '../../lib/booking-api';
|
|
2
|
+
import type { AdminPromoApplicationScope, ItineraryDisplayStep } from '../../lib/booking-api';
|
|
3
3
|
|
|
4
4
|
export type AdminAmendmentAdjustmentMode =
|
|
5
5
|
| 'FIXED_AMOUNT'
|
|
@@ -52,6 +52,7 @@ export type ProviderDashboardChangeBookingPayload = {
|
|
|
52
52
|
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
53
53
|
cancellationPolicyId?: string | null;
|
|
54
54
|
promoCode?: string | null;
|
|
55
|
+
promoApplicationScope?: AdminPromoApplicationScope | null;
|
|
55
56
|
newTotalAmount?: number;
|
|
56
57
|
additionalHoursCount?: number | null;
|
|
57
58
|
pricingAdjustment?: {
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
type Product,
|
|
19
19
|
type ReturnOption,
|
|
20
20
|
} from '../../lib/booking-api';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
filterAvailabilitiesAfterCurrentTime,
|
|
23
|
+
filterAvailabilitiesAfterPublicBookingCutoff,
|
|
24
|
+
} from '../../lib/booking/booking-cutoffs';
|
|
22
25
|
import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
|
|
23
26
|
import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
|
|
24
27
|
import {
|
|
@@ -628,7 +631,7 @@ export function useStandardBookingAvailability({
|
|
|
628
631
|
});
|
|
629
632
|
|
|
630
633
|
return isAdmin
|
|
631
|
-
? currentOrFutureAvailabilities
|
|
634
|
+
? filterAvailabilitiesAfterCurrentTime(currentOrFutureAvailabilities, bookingCutoffNow)
|
|
632
635
|
: filterAvailabilitiesAfterPublicBookingCutoff(
|
|
633
636
|
currentOrFutureAvailabilities,
|
|
634
637
|
bookingCutoffNow,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type AdminAmendmentOperationSnapshot,
|
|
14
14
|
type AdminAmendmentRefundDisposition,
|
|
15
15
|
type AdminFeAuthoritativeReceipt,
|
|
16
|
+
type AdminPromoApplicationScope,
|
|
16
17
|
type Availability,
|
|
17
18
|
type ChangeBookingQuotePricingDriftDetail,
|
|
18
19
|
type ChangeBookingQuoteTicketPricingTrace,
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
buildAdminChangePaymentChoiceData,
|
|
60
61
|
buildAdminChangePayNowCheckoutModalData,
|
|
61
62
|
confirmAdminChangeBookingWithoutPayment,
|
|
63
|
+
shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
|
|
62
64
|
type AdminChangeCheckoutModalData,
|
|
63
65
|
type AdminChangePaymentChoiceData,
|
|
64
66
|
} from './admin-change-payment-choice-runner';
|
|
@@ -66,6 +68,10 @@ import {
|
|
|
66
68
|
confirmFreeAdminCustomerChange,
|
|
67
69
|
quoteAdminCustomerChangeForCheckout,
|
|
68
70
|
} from './admin-change-customer-quote-runner';
|
|
71
|
+
import {
|
|
72
|
+
buildAdminChangeV2QuoteRequest,
|
|
73
|
+
quoteAdminChangeV2ForUi,
|
|
74
|
+
} from './admin-change-v2-quote-request';
|
|
69
75
|
import {
|
|
70
76
|
reservationHoldHasExpired,
|
|
71
77
|
RESERVATION_HOLD_EXPIRED_MESSAGE,
|
|
@@ -135,6 +141,7 @@ export interface UseAdminChangeCheckoutControllerParams {
|
|
|
135
141
|
onChangeBooking?: ChangeBookingFlowProps['onChangeBooking'];
|
|
136
142
|
cancellationPolicyId: string | null;
|
|
137
143
|
appliedPromoCode: string | null;
|
|
144
|
+
promoApplicationScope: AdminPromoApplicationScope;
|
|
138
145
|
displayChangeFlowProposedTotalWithEditableLines: number;
|
|
139
146
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
140
147
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
@@ -236,6 +243,7 @@ export function useAdminChangeCheckoutController({
|
|
|
236
243
|
onChangeBooking,
|
|
237
244
|
cancellationPolicyId,
|
|
238
245
|
appliedPromoCode,
|
|
246
|
+
promoApplicationScope,
|
|
239
247
|
displayChangeFlowProposedTotalWithEditableLines,
|
|
240
248
|
providerPricingOverrides,
|
|
241
249
|
mergedProviderAdditionalAdjustments,
|
|
@@ -290,8 +298,28 @@ export function useAdminChangeCheckoutController({
|
|
|
290
298
|
setShowCheckoutModal(false);
|
|
291
299
|
setCheckoutModalData(null);
|
|
292
300
|
setCheckoutClientSecret('');
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
const handleCheckoutClose = useCallback(() => {
|
|
304
|
+
if (paymentSubmitInFlightRef.current) return;
|
|
305
|
+
cancelPendingReservation();
|
|
293
306
|
setError('');
|
|
294
|
-
|
|
307
|
+
// An existing-booking Pay now attempt owns an immutable quote/payment-choice snapshot.
|
|
308
|
+
// Closing Stripe must return to that choice instead of making the user create a new quote.
|
|
309
|
+
if (shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(
|
|
310
|
+
isProviderDashboardChange,
|
|
311
|
+
isChangeBookingContext,
|
|
312
|
+
adminChoiceData,
|
|
313
|
+
)) {
|
|
314
|
+
setShowAdminPaymentChoice(true);
|
|
315
|
+
}
|
|
316
|
+
}, [
|
|
317
|
+
adminChoiceData?.pricingQuoteId,
|
|
318
|
+
cancelPendingReservation,
|
|
319
|
+
isChangeBookingContext,
|
|
320
|
+
isProviderDashboardChange,
|
|
321
|
+
setError,
|
|
322
|
+
]);
|
|
295
323
|
|
|
296
324
|
const cancelPendingReservationBestEffort = useCallback(() => {
|
|
297
325
|
if (paymentSubmitInFlightRef.current) return;
|
|
@@ -324,6 +352,7 @@ export function useAdminChangeCheckoutController({
|
|
|
324
352
|
const buildProviderChangePayload = (
|
|
325
353
|
availabilityProductOptionId: string,
|
|
326
354
|
bookingItems: AdminChangeBookingItem[],
|
|
355
|
+
authoritativeQuote: AdminChangeLatestQuote | null = latestChangeQuote,
|
|
327
356
|
): ProviderDashboardChangeBookingPayload | null => {
|
|
328
357
|
return buildAdminChangeProviderPayload({
|
|
329
358
|
selectedAvailability,
|
|
@@ -336,13 +365,14 @@ export function useAdminChangeCheckoutController({
|
|
|
336
365
|
cancellationPolicyId,
|
|
337
366
|
initialCancellationPolicyId: initialValues?.cancellationPolicyId ?? null,
|
|
338
367
|
appliedPromoCode,
|
|
368
|
+
promoApplicationScope,
|
|
339
369
|
newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
|
|
340
370
|
providerPricingOverrides,
|
|
341
371
|
mergedProviderAdditionalAdjustments,
|
|
342
372
|
adminStructuredAdjustments,
|
|
343
373
|
refundDispositionsByOperationId,
|
|
344
|
-
pricingV2QuoteId:
|
|
345
|
-
pricingV2QuotedTotal:
|
|
374
|
+
pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
|
|
375
|
+
pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
|
|
346
376
|
providerApplyAuthoritativeReceipt,
|
|
347
377
|
previousPassengerCount: changeFlowInitialTicketCount,
|
|
348
378
|
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
@@ -437,8 +467,40 @@ export function useAdminChangeCheckoutController({
|
|
|
437
467
|
let changeIntentIdForCheckout: string | undefined;
|
|
438
468
|
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
439
469
|
let confirmedChangeAmountDueForCheckout: number | null = null;
|
|
470
|
+
let authoritativeChangeQuoteForCheckout = latestChangeQuote;
|
|
440
471
|
|
|
441
|
-
if (
|
|
472
|
+
if (isProviderDashboardChange && isChangeBookingContext) {
|
|
473
|
+
const bookingReference = initialValues?.bookingReference?.trim();
|
|
474
|
+
if (!bookingReference) throw new Error('Missing booking reference.');
|
|
475
|
+
authoritativeChangeQuoteForCheckout = await quoteAdminChangeV2ForUi(
|
|
476
|
+
buildAdminChangeV2QuoteRequest({
|
|
477
|
+
bookingReference,
|
|
478
|
+
lastName,
|
|
479
|
+
parentProductId: product.productId,
|
|
480
|
+
optionId: availabilityProductOptionId,
|
|
481
|
+
selectedAvailability,
|
|
482
|
+
pickupLocationId,
|
|
483
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
484
|
+
bookingItems,
|
|
485
|
+
addOnSelections,
|
|
486
|
+
cancellationPolicyId,
|
|
487
|
+
promoCode: appliedPromoCode,
|
|
488
|
+
promoApplicationScope,
|
|
489
|
+
structuredAdjustments: adminStructuredAdjustments,
|
|
490
|
+
refundDispositionsByOperationId,
|
|
491
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
492
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
493
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
494
|
+
}),
|
|
495
|
+
{
|
|
496
|
+
total: changeFlowNewBookingTotal,
|
|
497
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
498
|
+
tax: effectiveTax,
|
|
499
|
+
},
|
|
500
|
+
currency,
|
|
501
|
+
);
|
|
502
|
+
setLatestChangeQuote(authoritativeChangeQuoteForCheckout);
|
|
503
|
+
} else if (isCustomerSelfServeChange) {
|
|
442
504
|
const quoteResult = await quoteAdminCustomerChangeForCheckout({
|
|
443
505
|
bookingReference: initialValues?.bookingReference,
|
|
444
506
|
lastName,
|
|
@@ -465,11 +527,18 @@ export function useAdminChangeCheckoutController({
|
|
|
465
527
|
});
|
|
466
528
|
changeBookingReferenceForPaidFlow = quoteResult.bookingReference;
|
|
467
529
|
setLatestChangeQuote(quoteResult.mergedQuote);
|
|
530
|
+
// React state is not updated synchronously. Everything in this checkout attempt must
|
|
531
|
+
// use the quote returned above, not the stale latestChangeQuote render closure.
|
|
532
|
+
authoritativeChangeQuoteForCheckout = quoteResult.mergedQuote;
|
|
468
533
|
confirmedChangeAmountDueForCheckout = quoteResult.decision.amountDueForCheckout;
|
|
469
534
|
|
|
470
535
|
if (quoteResult.decision.kind === 'free') {
|
|
471
536
|
if (onChangeBooking) {
|
|
472
|
-
const providerPayload = buildProviderChangePayload(
|
|
537
|
+
const providerPayload = buildProviderChangePayload(
|
|
538
|
+
availabilityProductOptionId,
|
|
539
|
+
bookingItems,
|
|
540
|
+
authoritativeChangeQuoteForCheckout,
|
|
541
|
+
);
|
|
473
542
|
if (!providerPayload) {
|
|
474
543
|
setError('No availability selected');
|
|
475
544
|
setLoading(false);
|
|
@@ -502,7 +571,7 @@ export function useAdminChangeCheckoutController({
|
|
|
502
571
|
const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
|
|
503
572
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
504
573
|
const amountDueForCheckout = isProviderDashboardChange && isChangeBookingContext
|
|
505
|
-
? Math.max(0,
|
|
574
|
+
? Math.max(0, authoritativeChangeQuoteForCheckout?.priceDiff ?? 0)
|
|
506
575
|
: isCustomerSelfServeChange
|
|
507
576
|
? confirmedChangeAmountDueForCheckout ??
|
|
508
577
|
Math.max(
|
|
@@ -547,14 +616,21 @@ export function useAdminChangeCheckoutController({
|
|
|
547
616
|
// quote-bound Stripe authorization and applies only after that authorization succeeds.
|
|
548
617
|
if (isProviderDashboardChange && isChangeBookingContext) {
|
|
549
618
|
const bookingReference = initialValues?.bookingReference?.trim();
|
|
550
|
-
const quoteId =
|
|
619
|
+
const quoteId = authoritativeChangeQuoteForCheckout?.quoteId?.trim();
|
|
551
620
|
if (!bookingReference) throw new Error('Missing booking reference.');
|
|
552
|
-
if (!quoteId ||
|
|
553
|
-
throw new Error(
|
|
621
|
+
if (!quoteId || authoritativeChangeQuoteForCheckout?.canProceed === false) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
authoritativeChangeQuoteForCheckout?.reasonIfBlocked ||
|
|
624
|
+
'The authoritative change quote is not ready.',
|
|
625
|
+
);
|
|
554
626
|
}
|
|
555
627
|
if (amountDueForCheckout <= 0) {
|
|
556
628
|
if (!onChangeBooking) throw new Error('Provider change handler is unavailable.');
|
|
557
|
-
const providerPayload = buildProviderChangePayload(
|
|
629
|
+
const providerPayload = buildProviderChangePayload(
|
|
630
|
+
availabilityProductOptionId,
|
|
631
|
+
bookingItems,
|
|
632
|
+
authoritativeChangeQuoteForCheckout,
|
|
633
|
+
);
|
|
558
634
|
if (!providerPayload) throw new Error('No availability selected');
|
|
559
635
|
await onChangeBooking(providerPayload);
|
|
560
636
|
onSuccess?.({ reservationReference: bookingReference });
|
|
@@ -591,7 +667,9 @@ export function useAdminChangeCheckoutController({
|
|
|
591
667
|
'Pay later to apply the change and add the amount to the booking balance, or collect a new card payment before applying it.',
|
|
592
668
|
confirmWithoutPaymentLabel: 'Pay later',
|
|
593
669
|
previousTotal: originalReceipt?.total,
|
|
594
|
-
newTotal:
|
|
670
|
+
newTotal:
|
|
671
|
+
authoritativeChangeQuoteForCheckout?.serverDisplay?.total ??
|
|
672
|
+
authoritativeChangeQuoteForCheckout?.quotedTotal,
|
|
595
673
|
finalizeExistingChangeInline: true,
|
|
596
674
|
preferConfirmWithoutPayment: true,
|
|
597
675
|
}));
|
|
@@ -941,6 +1019,7 @@ export function useAdminChangeCheckoutController({
|
|
|
941
1019
|
showAdminPaymentChoice,
|
|
942
1020
|
adminChoiceData,
|
|
943
1021
|
cancelPendingReservation,
|
|
1022
|
+
handleCheckoutClose,
|
|
944
1023
|
handleCheckout,
|
|
945
1024
|
handleConfirmWithoutPayment,
|
|
946
1025
|
handlePayNow,
|
|
@@ -326,9 +326,9 @@ export function useAdminChangeQuoteDisplayState({
|
|
|
326
326
|
productChanged: selectedParentProductChanged,
|
|
327
327
|
});
|
|
328
328
|
const hasChangeSelection =
|
|
329
|
-
isProviderDashboardChange
|
|
329
|
+
(isProviderDashboardChange
|
|
330
330
|
? changeSelectionDetails.hasOperationalChangesFromInitial
|
|
331
|
-
: changeSelectionDetails.hasChangesFromInitial;
|
|
331
|
+
: changeSelectionDetails.hasChangesFromInitial) || Boolean(appliedPromoCode);
|
|
332
332
|
|
|
333
333
|
const {
|
|
334
334
|
providerTotalsPreview,
|