@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
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Staff removals default to a pending refund. The refund remains a separate,
|
|
89
|
+
* reviewed action; this only removes the required choice from the change form.
|
|
90
|
+
*/
|
|
91
|
+
export function defaultPendingRefundDispositions(
|
|
92
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
93
|
+
dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
|
|
94
|
+
): Record<string, AdminAmendmentRefundDisposition> {
|
|
95
|
+
if (operations.length === 0) return dispositionsByOperationId;
|
|
96
|
+
|
|
97
|
+
return operations.reduce<Record<string, AdminAmendmentRefundDisposition>>(
|
|
98
|
+
(next, operation) => {
|
|
99
|
+
next[operation.operationId] = 'PENDING_REFUND';
|
|
100
|
+
return next;
|
|
101
|
+
},
|
|
102
|
+
{ ...dispositionsByOperationId },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Omitting a disposition for a return-price treatment operation tells the server
|
|
108
|
+
* to retain the original paid return-price floor.
|
|
109
|
+
*/
|
|
110
|
+
export function defaultKeepOriginalReturnPriceDispositions(
|
|
111
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
112
|
+
dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
|
|
113
|
+
): Record<string, AdminAmendmentRefundDisposition> {
|
|
114
|
+
if (operations.length === 0) return dispositionsByOperationId;
|
|
115
|
+
|
|
116
|
+
const returnOperationIds = new Set(operations.map((operation) => operation.operationId));
|
|
117
|
+
return Object.fromEntries(
|
|
118
|
+
Object.entries(dispositionsByOperationId).filter(
|
|
119
|
+
([operationId]) => !returnOperationIds.has(operationId),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -74,10 +74,79 @@ export function normalizeReturnPriceTreatment(
|
|
|
74
74
|
: 'KEEP_FLOOR';
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export
|
|
77
|
+
export type AdminRemovalOperationLabelContext = {
|
|
78
|
+
sourceProductName?: string | null;
|
|
79
|
+
targetProductName?: string | null;
|
|
80
|
+
promoCode?: string | null;
|
|
81
|
+
manualAdjustmentLabelsById?: Readonly<Record<string, string>>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function conciseTourName(name: string): string {
|
|
85
|
+
const beforeTourDescription = name.split(/\s+Tour:\s*/i)[0]?.trim() || name.trim();
|
|
86
|
+
return beforeTourDescription.replace(/\s+Tour$/i, '').trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function adminRemovalOperationLabel(
|
|
90
|
+
operation: AdminAmendmentOperationSnapshot,
|
|
91
|
+
context: AdminRemovalOperationLabelContext = {},
|
|
92
|
+
): string {
|
|
93
|
+
if (operation.type === 'REVERSE_MANUAL_ADJUSTMENT') {
|
|
94
|
+
const adjustmentId = operation.componentKey.startsWith('manual_adjustment:')
|
|
95
|
+
? operation.componentKey.slice('manual_adjustment:'.length)
|
|
96
|
+
: '';
|
|
97
|
+
const label = operation.metadata?.displayName?.trim() || (
|
|
98
|
+
adjustmentId
|
|
99
|
+
? context.manualAdjustmentLabelsById?.[adjustmentId]?.trim()
|
|
100
|
+
: ''
|
|
101
|
+
);
|
|
102
|
+
return label ? `Remove adjustment: ${label}` : 'Remove existing adjustment';
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
operation.type === 'ADD_MANUAL_ADJUSTMENT' &&
|
|
106
|
+
operation.componentKey.startsWith('manual_adjustment:promo_') &&
|
|
107
|
+
context.promoCode?.trim()
|
|
108
|
+
) {
|
|
109
|
+
return `Apply promo to entire booking: ${context.promoCode.trim().toUpperCase()}`;
|
|
110
|
+
}
|
|
111
|
+
if (operation.type === 'ADD_MANUAL_ADJUSTMENT') {
|
|
112
|
+
const adjustmentId = operation.componentKey.startsWith('manual_adjustment:')
|
|
113
|
+
? operation.componentKey.slice('manual_adjustment:'.length)
|
|
114
|
+
: '';
|
|
115
|
+
const label = adjustmentId
|
|
116
|
+
? context.manualAdjustmentLabelsById?.[adjustmentId]?.trim()
|
|
117
|
+
: '';
|
|
118
|
+
if (label) return label;
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
operation.type === 'MOVE_SERVICE' &&
|
|
122
|
+
context.sourceProductName?.trim() &&
|
|
123
|
+
context.targetProductName?.trim()
|
|
124
|
+
) {
|
|
125
|
+
return `Change tour: ${conciseTourName(context.sourceProductName)} → ${conciseTourName(context.targetProductName)}`;
|
|
126
|
+
}
|
|
78
127
|
const component = operation.componentKey.split(':').slice(1).join(':') || operation.componentKey;
|
|
79
128
|
const quantity = operation.quantityDelta ? ` (${Math.abs(operation.quantityDelta)})` : '';
|
|
80
|
-
|
|
129
|
+
const operationName = operation.type.replaceAll('_', ' ').toLowerCase();
|
|
130
|
+
return `${operationName.charAt(0).toUpperCase()}${operationName.slice(1)}: ${component}${quantity}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function refundTreatmentDisplayValue(
|
|
134
|
+
operation: AdminAmendmentOperationSnapshot,
|
|
135
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
136
|
+
removedValueByOperationId: Readonly<Record<string, number>>,
|
|
137
|
+
refundCandidate: number,
|
|
138
|
+
): number | undefined {
|
|
139
|
+
const removedValue = removedValueByOperationId[operation.operationId];
|
|
140
|
+
if (!Number.isFinite(removedValue)) return undefined;
|
|
141
|
+
|
|
142
|
+
// With one decision, the server's aggregate refund candidate is the exact amount
|
|
143
|
+
// that can be returned after first reducing any unpaid booking balance. Keep the
|
|
144
|
+
// gross removed value available to the settlement helpers, but do not present it
|
|
145
|
+
// to an operator as if the entire amount were refundable.
|
|
146
|
+
if (operations.length === 1 && Number.isFinite(refundCandidate)) {
|
|
147
|
+
return Math.min(Math.max(0, refundCandidate), Math.max(0, removedValue));
|
|
148
|
+
}
|
|
149
|
+
return removedValue;
|
|
81
150
|
}
|
|
82
151
|
|
|
83
152
|
export function refundDecisionsComplete(
|
|
@@ -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
|
+
}
|
|
@@ -177,6 +177,10 @@ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
|
|
|
177
177
|
label?: string;
|
|
178
178
|
amount?: number;
|
|
179
179
|
quantity?: number;
|
|
180
|
+
reference?: string | null;
|
|
181
|
+
identity?: {
|
|
182
|
+
componentId?: string | null;
|
|
183
|
+
} | null;
|
|
180
184
|
priceBasis?: import('../../lib/booking-api').PriceBasisSnapshot | null;
|
|
181
185
|
}>;
|
|
182
186
|
} | null;
|
|
@@ -330,6 +330,11 @@
|
|
|
330
330
|
padding: 0.5rem 0.85rem;
|
|
331
331
|
min-height: 2.5rem;
|
|
332
332
|
}
|
|
333
|
+
.booking-flow-preflight .admin-custom-receipt-apply {
|
|
334
|
+
box-sizing: border-box;
|
|
335
|
+
padding: 0.75rem 1.5rem;
|
|
336
|
+
min-height: 2.75rem;
|
|
337
|
+
}
|
|
333
338
|
|
|
334
339
|
/* Labels */
|
|
335
340
|
.booking-flow-preflight label {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const CHANGE_BOOKING_ERROR_MESSAGES: Record<string, string> = {
|
|
2
|
+
pricing_v2_unsupported_features:
|
|
3
|
+
'Some selected booking options do not work together. Review the highlighted selections before continuing.',
|
|
4
|
+
pricing_v2_capacity_unavailable:
|
|
5
|
+
'There is not enough availability for this change. Choose another time or reduce the number of guests.',
|
|
6
|
+
pricing_v2_availability_required:
|
|
7
|
+
'Choose an available date and time before continuing.',
|
|
8
|
+
pricing_v2_availability_mismatch:
|
|
9
|
+
'The selected date or time is no longer available for this tour. Choose another option and try again.',
|
|
10
|
+
pricing_v2_invalid_passenger_counts:
|
|
11
|
+
'Review the number and types of guests before continuing.',
|
|
12
|
+
pricing_v2_product_option_not_found:
|
|
13
|
+
'The selected tour option is no longer available. Refresh and choose another option.',
|
|
14
|
+
pricing_v2_price_not_available:
|
|
15
|
+
'A confirmed price is not available for this selection. Choose another option or try again.',
|
|
16
|
+
pricing_v2_return_price_not_found:
|
|
17
|
+
'A confirmed price is not available for the selected return. Choose another return time.',
|
|
18
|
+
pricing_v2_itinerary_not_available:
|
|
19
|
+
'An itinerary is not available for this selection. Choose another date, time, or pickup location.',
|
|
20
|
+
pricing_v2_invalid_additional_hours:
|
|
21
|
+
'Review the number of additional hours before continuing.',
|
|
22
|
+
pricing_v2_additional_hours_not_supported:
|
|
23
|
+
'Additional hours are not available for the selected tour.',
|
|
24
|
+
pricing_v2_dependent_add_on_not_available:
|
|
25
|
+
'A linked add-on is not available with this change. Review the linked booking before continuing.',
|
|
26
|
+
pricing_v2_dependent_add_on_currency_mismatch:
|
|
27
|
+
'A linked add-on uses a different currency and cannot be changed with this booking.',
|
|
28
|
+
pricing_v2_invalid_admin_adjustment:
|
|
29
|
+
'Review the custom pricing adjustment before continuing.',
|
|
30
|
+
pricing_v2_cancellation_policy_not_authorized:
|
|
31
|
+
'The selected cancellation policy cannot be used for this booking.',
|
|
32
|
+
pricing_v2_cancellation_upgrade_fee_not_found:
|
|
33
|
+
'A confirmed price is not available for the selected cancellation-policy upgrade.',
|
|
34
|
+
pricing_v2_pricing_profile_not_authorized:
|
|
35
|
+
'The selected pricing option cannot be used for this booking.',
|
|
36
|
+
pricing_v2_non_no_change_not_implemented:
|
|
37
|
+
'This combination of changes is not supported yet. Try changing one booking option at a time.',
|
|
38
|
+
pricing_v2_admin_amendment_payment_disabled:
|
|
39
|
+
'Payments for booking changes are temporarily unavailable. Try again later.',
|
|
40
|
+
pricing_v2_admin_apply_disabled:
|
|
41
|
+
'Saving booking changes is temporarily unavailable. Try again later.',
|
|
42
|
+
pricing_v2_admin_quote_disabled:
|
|
43
|
+
'Pricing booking changes is temporarily unavailable. Try again later.',
|
|
44
|
+
apply_v2_not_enabled:
|
|
45
|
+
'Booking changes are temporarily unavailable. Try again later.',
|
|
46
|
+
admin_refund_disposition_required:
|
|
47
|
+
'Choose how to handle the removed paid value before continuing.',
|
|
48
|
+
admin_refund_disposition_not_supported:
|
|
49
|
+
'The selected refund treatment is not available for this change.',
|
|
50
|
+
admin_refund_disposition_invalid:
|
|
51
|
+
'Choose a valid refund treatment before continuing.',
|
|
52
|
+
amendment_refund_disposition_not_supported:
|
|
53
|
+
'The selected refund treatment is not available for this change.',
|
|
54
|
+
amendment_quote_blocked:
|
|
55
|
+
'This booking change cannot be completed with the selected options.',
|
|
56
|
+
admin_ledger_requires_review_or_repair:
|
|
57
|
+
'This booking needs a financial review before it can be changed.',
|
|
58
|
+
amendment_ledger_summary_missing:
|
|
59
|
+
'This booking needs a financial review before it can be changed.',
|
|
60
|
+
amendment_ledger_components_missing:
|
|
61
|
+
'This booking needs a financial review before it can be changed.',
|
|
62
|
+
booking_amendment_ledger_pointer_mismatch:
|
|
63
|
+
'This booking changed after the page was opened. Refresh it before trying again.',
|
|
64
|
+
promo_change_only_no_eligible_new_value:
|
|
65
|
+
'This promo only applies to new charges. Make a priced change, or choose Entire active booking.',
|
|
66
|
+
structured_adjustment_basis_is_zero:
|
|
67
|
+
'This credit needs a value to apply to. Make a priced booking change, or choose All active tickets, Entire active booking, or Selected components.',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function readableList(values: string[]): string {
|
|
71
|
+
if (values.length <= 1) return values[0] ?? '';
|
|
72
|
+
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
|
73
|
+
return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function incompatibleAddOnChangeError(
|
|
77
|
+
addOnNames: readonly string[],
|
|
78
|
+
targetTourName: string | null | undefined,
|
|
79
|
+
): string {
|
|
80
|
+
const names = Array.from(new Set(addOnNames.map((name) => name.trim()).filter(Boolean)));
|
|
81
|
+
const tour = targetTourName?.trim();
|
|
82
|
+
const itemLabel = readableList(names) || 'The selected add-on';
|
|
83
|
+
const availability = tour
|
|
84
|
+
? `${itemLabel} ${names.length > 1 ? "aren't" : "isn't"} available with ${tour}.`
|
|
85
|
+
: `${itemLabel} ${names.length > 1 ? "aren't" : "isn't"} available with the selected tour.`;
|
|
86
|
+
const action = names.length > 1
|
|
87
|
+
? 'Remove these add-ons or choose a different tour.'
|
|
88
|
+
: 'Remove the add-on or choose a different tour.';
|
|
89
|
+
return `${availability} ${action}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function errorCodeFromMessage(message: string): string | null {
|
|
93
|
+
const match = message.match(
|
|
94
|
+
/\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|quote|structured_adjustment)_[a-z0-9_]+/i,
|
|
95
|
+
);
|
|
96
|
+
return match?.[0]?.toLowerCase() ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Converts stable server reason codes into operator-facing copy while preserving
|
|
101
|
+
* messages that are already written for people.
|
|
102
|
+
*/
|
|
103
|
+
export function friendlyChangeBookingError(message: string | null | undefined): string {
|
|
104
|
+
const trimmed = message?.trim() ?? '';
|
|
105
|
+
if (!trimmed) return '';
|
|
106
|
+
|
|
107
|
+
const code = errorCodeFromMessage(trimmed);
|
|
108
|
+
if (!code) return trimmed;
|
|
109
|
+
|
|
110
|
+
const exact = CHANGE_BOOKING_ERROR_MESSAGES[code];
|
|
111
|
+
if (exact) return exact;
|
|
112
|
+
|
|
113
|
+
if (code.startsWith('admin_adjustment_refund_disposition_required')) {
|
|
114
|
+
return 'Choose how to handle the removed adjustment value before continuing.';
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
code.includes('expired') ||
|
|
118
|
+
code.includes('hash_mismatch') ||
|
|
119
|
+
code.includes('revision_mismatch') ||
|
|
120
|
+
code.includes('receipt_mismatch') ||
|
|
121
|
+
code.includes('status_mismatch') ||
|
|
122
|
+
code.includes('stale')
|
|
123
|
+
) {
|
|
124
|
+
return 'This booking or price changed after the page was opened. Refresh it and try again.';
|
|
125
|
+
}
|
|
126
|
+
if (code.includes('capacity') || code.includes('unavailable')) {
|
|
127
|
+
return 'The selected booking option is no longer available. Choose another option and try again.';
|
|
128
|
+
}
|
|
129
|
+
if (code.includes('required') || code.includes('missing')) {
|
|
130
|
+
return 'More information is required before this booking change can be completed. Review the selections and try again.';
|
|
131
|
+
}
|
|
132
|
+
if (code.includes('invalid') || code.includes('mismatch')) {
|
|
133
|
+
return 'This booking change could not be verified. Refresh the booking and try again.';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return 'This booking change cannot be completed with the selected options. Review the selections and try again.';
|
|
137
|
+
}
|
|
@@ -95,6 +95,70 @@ export function resolveTicketQtyFromQuantities(
|
|
|
95
95
|
return undefined;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Carry the original ticket counts into an amendment only when the selected
|
|
100
|
+
* availability can price those categories. This prevents a private shuttle's
|
|
101
|
+
* hidden RESOURCE count from leaking into a regular-tour quote (and vice versa).
|
|
102
|
+
*/
|
|
103
|
+
export function deriveCompatibleInitialBookingQuantities(
|
|
104
|
+
bookingItems: ReadonlyArray<{ category?: string | null; count?: number | null }>,
|
|
105
|
+
availability: Availability,
|
|
106
|
+
): Record<string, number> {
|
|
107
|
+
const allowedCategories = new Map<string, string>();
|
|
108
|
+
for (const category of [
|
|
109
|
+
...(availability.rates ?? []).map((rate) => rate.category),
|
|
110
|
+
...(availability.pricesByCategory?.retailPrices ?? []).map((price) => price.category),
|
|
111
|
+
]) {
|
|
112
|
+
const trimmed = category?.trim();
|
|
113
|
+
if (trimmed) allowedCategories.set(trimmed.toUpperCase(), trimmed);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const quantities: Record<string, number> = {};
|
|
117
|
+
for (const item of bookingItems) {
|
|
118
|
+
const sourceCategory = item.category?.trim();
|
|
119
|
+
if (!sourceCategory) continue;
|
|
120
|
+
const normalizedCategory = sourceCategory.toUpperCase();
|
|
121
|
+
const matchedCategory = allowedCategories.get(normalizedCategory);
|
|
122
|
+
if (allowedCategories.size > 0 && !matchedCategory) continue;
|
|
123
|
+
if (allowedCategories.size === 0 && availability.productType === 'STANDARD' && normalizedCategory === 'RESOURCE') {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (allowedCategories.size === 0 && availability.productType === 'PRIVATE_SHUTTLE' && normalizedCategory !== 'RESOURCE') {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
quantities[matchedCategory ?? sourceCategory] = Math.max(0, Number(item.count) || 0);
|
|
130
|
+
}
|
|
131
|
+
return quantities;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveChangeBookingCancellationPolicyId(
|
|
135
|
+
currentPolicyId: string | null | undefined,
|
|
136
|
+
policies: NonNullable<PricingConfig['cancellationPolicies']>,
|
|
137
|
+
currency: string,
|
|
138
|
+
forceTargetDefault: boolean,
|
|
139
|
+
): string | null {
|
|
140
|
+
if (policies.length === 0) return null;
|
|
141
|
+
const current = currentPolicyId?.trim();
|
|
142
|
+
if (!forceTargetDefault && current && policies.some((policy) => policy.id === current)) {
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
return [...policies]
|
|
146
|
+
.sort((a, b) => (a.feeByCurrency[currency] ?? 0) - (b.feeByCurrency[currency] ?? 0))[0]
|
|
147
|
+
?.id ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isProductFamilyReceiptConversion(
|
|
151
|
+
originalLines: PriceSummaryLine[],
|
|
152
|
+
resultingLines: PriceSummaryLine[],
|
|
153
|
+
): boolean {
|
|
154
|
+
if (resultingLines.length === 0) return false;
|
|
155
|
+
const containsResourceTicket = (lines: PriceSummaryLine[]): boolean =>
|
|
156
|
+
lines.some(
|
|
157
|
+
(line) => line.kind === 'ticket' && line.category.trim().toUpperCase() === 'RESOURCE',
|
|
158
|
+
);
|
|
159
|
+
return containsResourceTicket(originalLines) !== containsResourceTicket(resultingLines);
|
|
160
|
+
}
|
|
161
|
+
|
|
98
162
|
export function mergePickerTicketQtyIntoPriceSummaryLines(
|
|
99
163
|
lines: PriceSummaryLine[],
|
|
100
164
|
quantities: Record<string, number>,
|
|
@@ -632,7 +696,8 @@ export function resolveInitialAvailabilityFromBooking(
|
|
|
632
696
|
bookingItems: Array<{ category: string; count: number }> | null | undefined,
|
|
633
697
|
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
634
698
|
currency: Currency,
|
|
635
|
-
originalSubtotalBeforeTax: number | undefined
|
|
699
|
+
originalSubtotalBeforeTax: number | undefined,
|
|
700
|
+
preferredFallbackProductOptionId?: string | null
|
|
636
701
|
): { selection: Availability | null; defer: boolean } {
|
|
637
702
|
const availId = initialAvailabilityId?.trim() || null;
|
|
638
703
|
const optId = initialProductOptionId?.trim() || null;
|
|
@@ -719,7 +784,17 @@ export function resolveInitialAvailabilityFromBooking(
|
|
|
719
784
|
}
|
|
720
785
|
}
|
|
721
786
|
|
|
722
|
-
const
|
|
787
|
+
const preferredFallback = preferredFallbackProductOptionId
|
|
788
|
+
? timesForSelectedDate.find(
|
|
789
|
+
(availability) =>
|
|
790
|
+
availability.productOptionId === preferredFallbackProductOptionId &&
|
|
791
|
+
availability.vacancies > 0,
|
|
792
|
+
)
|
|
793
|
+
: null;
|
|
794
|
+
const fallback =
|
|
795
|
+
preferredFallback ??
|
|
796
|
+
timesForSelectedDate.find((availability) => availability.vacancies > 0) ??
|
|
797
|
+
null;
|
|
723
798
|
return { selection: fallback, defer: false };
|
|
724
799
|
}
|
|
725
800
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
roundMoney,
|
|
6
6
|
type ChangeQuoteUiSlice,
|
|
7
7
|
} from '../../lib/booking/change-flow-pricing';
|
|
8
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
8
9
|
|
|
9
10
|
const CHANGE_PAYMENT_CONFIRMATION_ERROR =
|
|
10
11
|
'This change requires payment, but the price could not be confirmed. Please refresh and try again.';
|
|
@@ -162,7 +163,9 @@ export function evaluateChangeBookingQuoteForCheckout(
|
|
|
162
163
|
} = params;
|
|
163
164
|
|
|
164
165
|
if (!quoteSlice.canProceed) {
|
|
165
|
-
throw new Error(
|
|
166
|
+
throw new Error(friendlyChangeBookingError(
|
|
167
|
+
quote.reasonIfBlocked || 'This booking change cannot be completed right now.',
|
|
168
|
+
));
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
const signedBalanceMajor = resolveSignedBalanceMajor(quote);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ChangeBookingQuoteResponse,
|
|
3
|
+
ItineraryDisplayStep,
|
|
4
|
+
} from '../../lib/booking-api';
|
|
2
5
|
import {
|
|
3
6
|
type ChangeQuoteUiSlice,
|
|
4
7
|
sliceChangeQuoteForUi,
|
|
@@ -11,10 +14,47 @@ import {
|
|
|
11
14
|
buildChangeBookingServerPreview,
|
|
12
15
|
} from '../../lib/booking/change-booking-server-preview';
|
|
13
16
|
import type { Currency } from './CurrencySwitcher';
|
|
17
|
+
import { selectableAdminAdjustmentComponentsFromQuote } from './admin-adjustment-components';
|
|
14
18
|
|
|
15
19
|
export type ChangeBookingServerPreview = ReturnType<typeof buildChangeBookingServerPreview>;
|
|
16
20
|
export type ChangeBookingMergedQuoteState = ReturnType<typeof mergeQuoteSliceWithServerPreview>;
|
|
17
21
|
|
|
22
|
+
function isItineraryDisplayStep(value: unknown): value is ItineraryDisplayStep {
|
|
23
|
+
if (value == null || typeof value !== 'object') return false;
|
|
24
|
+
const step = value as Partial<ItineraryDisplayStep>;
|
|
25
|
+
return (
|
|
26
|
+
typeof step.stepType === 'string' &&
|
|
27
|
+
typeof step.time === 'string' &&
|
|
28
|
+
typeof step.place === 'string'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads the canonical itinerary embedded in a Pricing V2 amendment quote.
|
|
34
|
+
*
|
|
35
|
+
* The server merges confirmed dependent add-on sessions into this itinerary, so change-booking
|
|
36
|
+
* comparison UIs must prefer it over a client-side product itinerary recomputation.
|
|
37
|
+
*/
|
|
38
|
+
export function authoritativeItineraryDisplayFromChangeQuote(
|
|
39
|
+
quote: ChangeBookingQuoteResponse,
|
|
40
|
+
): ItineraryDisplayStep[] | null {
|
|
41
|
+
const pricingQuote = quote.pricingQuote ?? quote.quote;
|
|
42
|
+
const rawItinerary = pricingQuote?.amendment?.targetState?.nonPriceState?.itinerary;
|
|
43
|
+
if (rawItinerary == null) return null;
|
|
44
|
+
|
|
45
|
+
let parsed: unknown = rawItinerary;
|
|
46
|
+
if (typeof rawItinerary === 'string') {
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(rawItinerary);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!Array.isArray(parsed) || !parsed.every(isItineraryDisplayStep)) return null;
|
|
55
|
+
return parsed.map((step) => ({ ...step }));
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
export function mergeQuoteSliceWithServerPreview(
|
|
19
59
|
slice: ChangeQuoteUiSlice,
|
|
20
60
|
quote: ChangeBookingQuoteResponse,
|
|
@@ -25,8 +65,11 @@ export function mergeQuoteSliceWithServerPreview(
|
|
|
25
65
|
...slice,
|
|
26
66
|
currency: (slice.currency || currency) as Currency,
|
|
27
67
|
serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
|
|
68
|
+
authoritativeItineraryDisplay: authoritativeItineraryDisplayFromChangeQuote(quote),
|
|
28
69
|
pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
|
|
29
70
|
ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
|
|
71
|
+
selectableAdjustmentComponents: selectableAdminAdjustmentComponentsFromQuote(quote),
|
|
72
|
+
reversibleAdjustments: quote.reversibleAdjustments ?? [],
|
|
30
73
|
/** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference". */
|
|
31
74
|
quotePreviousTotalCents: quote.previousTotalCents,
|
|
32
75
|
quoteNewTotalCents: quote.newTotalCents,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AddOn } from '../../lib/booking-api';
|
|
2
|
+
import type { AddOnSelection } from './AddOnsSection';
|
|
3
|
+
|
|
4
|
+
export function incompatibleAddOnSelections(
|
|
5
|
+
selections: AddOnSelection[],
|
|
6
|
+
availableAddOns: AddOn[],
|
|
7
|
+
catalogLoaded: boolean,
|
|
8
|
+
): AddOnSelection[] {
|
|
9
|
+
if (!catalogLoaded) return [];
|
|
10
|
+
const availableIds = new Set(availableAddOns.map((addOn) => addOn.addOnId));
|
|
11
|
+
return selections.filter((selection) => !availableIds.has(selection.addOnId));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function removeAddOnSelections(
|
|
15
|
+
selections: AddOnSelection[],
|
|
16
|
+
addOnId: string,
|
|
17
|
+
): AddOnSelection[] {
|
|
18
|
+
return selections.filter((selection) => selection.addOnId !== addOnId);
|
|
19
|
+
}
|
|
@@ -20,6 +20,20 @@ export function parseAvailabilityDateTime(value: string): Date {
|
|
|
20
20
|
return parseISO(hasExplicitOffset ? value : `${value}Z`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function privateShuttleInitialDateKey(
|
|
24
|
+
value: string | null | undefined,
|
|
25
|
+
timezone: string,
|
|
26
|
+
): string {
|
|
27
|
+
const trimmed = value?.trim();
|
|
28
|
+
if (!trimmed) return '';
|
|
29
|
+
if (DATE_ONLY_REGEX.test(trimmed)) return trimmed;
|
|
30
|
+
try {
|
|
31
|
+
return formatInTimeZone(parseAvailabilityDateTime(trimmed), timezone, 'yyyy-MM-dd');
|
|
32
|
+
} catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
|
|
24
38
|
return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
|
|
25
39
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function resolvePrivateShuttleCancellationPolicyId(
|
|
2
|
+
currentPolicyId: string | null | undefined,
|
|
3
|
+
availablePolicyIds: readonly string[],
|
|
4
|
+
): string | null {
|
|
5
|
+
const validIds = availablePolicyIds.map((id) => id.trim()).filter(Boolean);
|
|
6
|
+
const current = currentPolicyId?.trim();
|
|
7
|
+
if (current && validIds.includes(current)) return current;
|
|
8
|
+
return validIds[0] ?? null;
|
|
9
|
+
}
|
|
@@ -309,7 +309,9 @@ export function usePrivateShuttleCheckoutController({
|
|
|
309
309
|
if (isProviderChangeMode && onChangeBooking) {
|
|
310
310
|
await applyPrivateShuttleProviderChange({
|
|
311
311
|
onChangeBooking,
|
|
312
|
+
parentProductId: productId,
|
|
312
313
|
selectedOption,
|
|
314
|
+
selectedAvailabilityId: selectedAvailability?.availabilityId ?? null,
|
|
313
315
|
selectedDate,
|
|
314
316
|
selectedStartTime,
|
|
315
317
|
billableResourceCount,
|
|
@@ -325,6 +327,9 @@ export function usePrivateShuttleCheckoutController({
|
|
|
325
327
|
activePromoCode,
|
|
326
328
|
totalPrice,
|
|
327
329
|
additionalHoursCount,
|
|
330
|
+
draftItineraryDestinations,
|
|
331
|
+
draftItineraryPlanningNotes,
|
|
332
|
+
itineraryDisplayItems,
|
|
328
333
|
isAdmin,
|
|
329
334
|
providerChangeAuthoritativeReceipt,
|
|
330
335
|
});
|