@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
|
@@ -123,7 +123,8 @@ export function PrivateShuttleCheckoutSection({
|
|
|
123
123
|
partnerDeferredInvoiceSubmitLabel,
|
|
124
124
|
}: PrivateShuttleCheckoutSectionProps) {
|
|
125
125
|
const amountDueToday = depositInfo ? depositInfo.depositAmount : totalPrice;
|
|
126
|
-
const
|
|
126
|
+
const displayedTotal = isProviderChangeMode ? totalPrice : amountDueToday;
|
|
127
|
+
const formattedDisplayedTotal = formatCurrencyAmount(displayedTotal, currency, locale);
|
|
127
128
|
const hasCancellationPolicies = Boolean(cancellationPolicies?.length || forcedCancellationPolicy);
|
|
128
129
|
|
|
129
130
|
return (
|
|
@@ -131,7 +132,7 @@ export function PrivateShuttleCheckoutSection({
|
|
|
131
132
|
<div className={`${styles.section} mt-6`}>
|
|
132
133
|
<PriceSummary
|
|
133
134
|
lines={priceSummaryLines}
|
|
134
|
-
total={
|
|
135
|
+
total={displayedTotal}
|
|
135
136
|
currency={currency}
|
|
136
137
|
locale={locale}
|
|
137
138
|
taxAmount={effectiveTaxAmount}
|
|
@@ -158,7 +159,7 @@ export function PrivateShuttleCheckoutSection({
|
|
|
158
159
|
size="sm"
|
|
159
160
|
t={t}
|
|
160
161
|
depositMode={
|
|
161
|
-
depositInfo
|
|
162
|
+
!isProviderChangeMode && depositInfo
|
|
162
163
|
? {
|
|
163
164
|
totalLabel: t('booking.deposit') || 'Deposit',
|
|
164
165
|
balanceAmount: depositInfo.balanceAmount,
|
|
@@ -166,9 +167,15 @@ export function PrivateShuttleCheckoutSection({
|
|
|
166
167
|
}
|
|
167
168
|
: undefined
|
|
168
169
|
}
|
|
169
|
-
hideSubtotal={!!depositInfo}
|
|
170
|
+
hideSubtotal={!isProviderChangeMode && !!depositInfo}
|
|
170
171
|
/>
|
|
171
|
-
{
|
|
172
|
+
{isProviderChangeMode ? (
|
|
173
|
+
<div className="mt-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900">
|
|
174
|
+
This is the updated booking total. Payments already made stay with this booking and are
|
|
175
|
+
applied first; only the remaining balance will be recorded as owing.
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
178
|
+
{!isProviderChangeMode && depositInfo && (
|
|
172
179
|
<div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
173
180
|
<p className="text-xs font-medium">
|
|
174
181
|
{(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
|
|
@@ -190,51 +197,78 @@ export function PrivateShuttleCheckoutSection({
|
|
|
190
197
|
|
|
191
198
|
<div className="border-t border-stone-200 pt-6 space-y-4">
|
|
192
199
|
<div className="space-y-4">
|
|
193
|
-
|
|
194
|
-
<div>
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
{isProviderChangeMode ? (
|
|
201
|
+
<div className="rounded-lg border border-stone-200 bg-stone-50 p-4">
|
|
202
|
+
<p className="m-0 text-sm font-semibold text-stone-900">Existing booking contact</p>
|
|
203
|
+
<div className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
|
204
|
+
<div>
|
|
205
|
+
<span className="block text-xs font-medium text-stone-500">Name</span>
|
|
206
|
+
<span className="text-stone-900">
|
|
207
|
+
{[firstName, lastName].filter(Boolean).join(' ') || 'Not provided'}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div>
|
|
211
|
+
<span className="block text-xs font-medium text-stone-500">Email</span>
|
|
212
|
+
<span className="text-stone-900">{email || 'Not provided'}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div>
|
|
215
|
+
<span className="block text-xs font-medium text-stone-500">Phone</span>
|
|
216
|
+
<span className="text-stone-900">{phoneNumber || 'Not provided'}</span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<p className="mb-0 mt-3 text-xs text-stone-600">
|
|
220
|
+
These customer details stay unchanged on the existing booking.
|
|
221
|
+
</p>
|
|
205
222
|
</div>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
) : (
|
|
224
|
+
<>
|
|
225
|
+
<div className={styles.contactGrid}>
|
|
226
|
+
<div>
|
|
227
|
+
<label className={styles.sectionLabel}>
|
|
228
|
+
{t('booking.firstName')} <span className={styles.required}>*</span>
|
|
229
|
+
</label>
|
|
230
|
+
<input
|
|
231
|
+
type="text"
|
|
232
|
+
value={firstName}
|
|
233
|
+
onChange={(e) => onFirstNameChange(e.target.value)}
|
|
234
|
+
placeholder={t('booking.firstNamePlaceholder')}
|
|
235
|
+
className={styles.input}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
<div>
|
|
239
|
+
<label className={styles.sectionLabel}>
|
|
240
|
+
{t('booking.lastName')} <span className={styles.required}>*</span>
|
|
241
|
+
</label>
|
|
242
|
+
<input
|
|
243
|
+
type="text"
|
|
244
|
+
value={lastName}
|
|
245
|
+
onChange={(e) => onLastNameChange(e.target.value)}
|
|
246
|
+
placeholder={t('booking.lastNamePlaceholder')}
|
|
247
|
+
className={styles.input}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
<div>
|
|
252
|
+
<label className={styles.sectionLabel}>
|
|
253
|
+
{t('common.emailForConfirmation')} <span className={styles.required}>*</span>
|
|
254
|
+
</label>
|
|
255
|
+
<input
|
|
256
|
+
type="email"
|
|
257
|
+
value={email}
|
|
258
|
+
onChange={(e) => onEmailChange(e.target.value)}
|
|
259
|
+
placeholder={t('common.emailPlaceholder')}
|
|
260
|
+
className={styles.input}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
<CheckoutOptionalPhoneFields
|
|
264
|
+
idPrefix="shuttle"
|
|
265
|
+
labelClassName={styles.sectionLabel}
|
|
266
|
+
phoneNumber={phoneNumber}
|
|
267
|
+
onPhoneNumberChange={onPhoneNumberChange}
|
|
268
|
+
t={t}
|
|
216
269
|
/>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<div>
|
|
220
|
-
<label className={styles.sectionLabel}>
|
|
221
|
-
{t('common.emailForConfirmation')} <span className={styles.required}>*</span>
|
|
222
|
-
</label>
|
|
223
|
-
<input
|
|
224
|
-
type="email"
|
|
225
|
-
value={email}
|
|
226
|
-
onChange={(e) => onEmailChange(e.target.value)}
|
|
227
|
-
placeholder={t('common.emailPlaceholder')}
|
|
228
|
-
className={styles.input}
|
|
229
|
-
/>
|
|
230
|
-
</div>
|
|
231
|
-
<CheckoutOptionalPhoneFields
|
|
232
|
-
idPrefix="shuttle"
|
|
233
|
-
labelClassName={styles.sectionLabel}
|
|
234
|
-
phoneNumber={phoneNumber}
|
|
235
|
-
onPhoneNumberChange={onPhoneNumberChange}
|
|
236
|
-
t={t}
|
|
237
|
-
/>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
238
272
|
{hasCancellationPolicies && (
|
|
239
273
|
<div>
|
|
240
274
|
<CancellationPolicySelector
|
|
@@ -250,10 +284,17 @@ export function PrivateShuttleCheckoutSection({
|
|
|
250
284
|
onPolicySelect={onCancellationPolicySelect}
|
|
251
285
|
forcedPolicy={forcedCancellationPolicy}
|
|
252
286
|
rowSubtitle={
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
287
|
+
isProviderChangeMode ? (
|
|
288
|
+
<>
|
|
289
|
+
This policy applies to the updated private booking. Payments already made stay
|
|
290
|
+
on the existing booking.
|
|
291
|
+
</>
|
|
292
|
+
) : (
|
|
293
|
+
<>
|
|
294
|
+
<b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
|
|
295
|
+
that you approve, will your deposit be locked in.
|
|
296
|
+
</>
|
|
297
|
+
)
|
|
257
298
|
}
|
|
258
299
|
/>
|
|
259
300
|
</div>
|
|
@@ -327,8 +368,8 @@ export function PrivateShuttleCheckoutSection({
|
|
|
327
368
|
? 'Processing...'
|
|
328
369
|
: (t('booking.creatingReservation') || 'Creating reservation...')
|
|
329
370
|
: isProviderChangeMode
|
|
330
|
-
? `Change booking (${
|
|
331
|
-
: `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${
|
|
371
|
+
? `Change booking (updated total ${formattedDisplayedTotal})`
|
|
372
|
+
: `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedDisplayedTotal})`}
|
|
332
373
|
</button>
|
|
333
374
|
<p className={styles.secureNote}>
|
|
334
375
|
{t('booking.securePayment') || 'Secure payment powered by Stripe'}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
|
|
2
|
+
|
|
3
|
+
export type AdminAdjustmentComponentOption = {
|
|
4
|
+
componentId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
type: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const NON_SELECTABLE_TYPES = new Set([
|
|
11
|
+
'TAX',
|
|
12
|
+
'ROUNDING',
|
|
13
|
+
'SYSTEM',
|
|
14
|
+
'PROMO_CODE',
|
|
15
|
+
'GIFT_CARD',
|
|
16
|
+
'DEAL',
|
|
17
|
+
'DISCOUNT',
|
|
18
|
+
'BOOKING_CHANGE',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function selectableAdminAdjustmentComponentsFromQuote(
|
|
22
|
+
quote: ChangeBookingQuoteResponse,
|
|
23
|
+
): AdminAdjustmentComponentOption[] {
|
|
24
|
+
const pricingQuote = quote.pricingQuote ?? quote.quote;
|
|
25
|
+
const lines = pricingQuote?.amendment?.updatedReceipt?.lineItems ?? [];
|
|
26
|
+
const byId = new Map<string, AdminAdjustmentComponentOption>();
|
|
27
|
+
|
|
28
|
+
lines.forEach((line) => {
|
|
29
|
+
const componentId = line.identity?.componentId?.trim() || line.reference?.trim();
|
|
30
|
+
const type = line.type?.trim().toUpperCase() || 'ITEM';
|
|
31
|
+
const amount = Number(line.amount ?? 0);
|
|
32
|
+
if (!componentId || !Number.isFinite(amount) || amount <= 0 || NON_SELECTABLE_TYPES.has(type)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
byId.set(componentId, {
|
|
36
|
+
componentId,
|
|
37
|
+
label: line.label?.trim() || type,
|
|
38
|
+
amount,
|
|
39
|
+
type,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return [...byId.values()];
|
|
44
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
|
|
2
|
+
|
|
3
|
+
export type AdminAdjustmentTaxBehavior = AdminCustomReceiptLine['taxBehavior'];
|
|
4
|
+
|
|
5
|
+
export type AdminAdjustmentTaxBehaviorOption = {
|
|
6
|
+
value: AdminAdjustmentTaxBehavior;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const CHARGE_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
|
|
11
|
+
{ value: 'TAXABLE', label: 'Taxable' },
|
|
12
|
+
{ value: 'NON_TAXABLE', label: 'Non-taxable' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const CREDIT_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
|
|
16
|
+
{ value: 'PRE_TAX_DISCOUNT', label: 'Pre-tax discount' },
|
|
17
|
+
{ value: 'POST_TAX_DISCOUNT', label: 'Post-tax discount' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function adminAdjustmentTaxBehaviorOptions(
|
|
21
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
22
|
+
): readonly AdminAdjustmentTaxBehaviorOption[] {
|
|
23
|
+
return amountSign === -1 ? CREDIT_OPTIONS : CHARGE_OPTIONS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeAdminAdjustmentTaxBehavior(
|
|
27
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
28
|
+
current: AdminAdjustmentTaxBehavior,
|
|
29
|
+
): AdminAdjustmentTaxBehavior {
|
|
30
|
+
const options = adminAdjustmentTaxBehaviorOptions(amountSign);
|
|
31
|
+
return options.some((option) => option.value === current)
|
|
32
|
+
? current
|
|
33
|
+
: options[0].value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AdminAdjustmentScope = AdminCustomReceiptLine['scope'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* New custom lines start as charges against added value. A credit against that
|
|
40
|
+
* scope has no basis until the amendment also adds priced value, so switching
|
|
41
|
+
* direction defaults to the existing active tickets instead.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeAdminAdjustmentScopeForSign(
|
|
44
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
45
|
+
current: AdminAdjustmentScope,
|
|
46
|
+
): AdminAdjustmentScope {
|
|
47
|
+
return amountSign === -1 && current === 'POSITIVE_AMENDMENT_VALUE'
|
|
48
|
+
? 'CURRENT_ACTIVE_TICKETS'
|
|
49
|
+
: current;
|
|
50
|
+
}
|
|
@@ -66,6 +66,16 @@ export function buildChangeBookingMinimumQuantities({
|
|
|
66
66
|
return minimums;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function shouldLockExistingAddOnQuantities({
|
|
70
|
+
isCustomerSelfServeChange,
|
|
71
|
+
isAdmin,
|
|
72
|
+
}: {
|
|
73
|
+
isCustomerSelfServeChange: boolean;
|
|
74
|
+
isAdmin: boolean;
|
|
75
|
+
}): boolean {
|
|
76
|
+
return isCustomerSelfServeChange && !isAdmin;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
export function resolveInitialChangeProductOptionId({
|
|
70
80
|
initialProductId,
|
|
71
81
|
initialProductOptionId,
|
|
@@ -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,10 +34,12 @@ 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[];
|
|
35
41
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
42
|
+
adminReverseAdjustmentIds?: string[];
|
|
36
43
|
refundDispositionsByOperationId: NonNullable<
|
|
37
44
|
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
38
45
|
>;
|
|
@@ -56,10 +63,12 @@ export function buildAdminChangeProviderPayload({
|
|
|
56
63
|
cancellationPolicyId,
|
|
57
64
|
initialCancellationPolicyId,
|
|
58
65
|
appliedPromoCode,
|
|
66
|
+
promoApplicationScope = 'CHANGE_ONLY',
|
|
59
67
|
newTotalAmount,
|
|
60
68
|
providerPricingOverrides,
|
|
61
69
|
mergedProviderAdditionalAdjustments,
|
|
62
70
|
adminStructuredAdjustments,
|
|
71
|
+
adminReverseAdjustmentIds = [],
|
|
63
72
|
refundDispositionsByOperationId,
|
|
64
73
|
pricingV2QuoteId,
|
|
65
74
|
pricingV2QuotedTotal,
|
|
@@ -92,6 +101,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
92
101
|
addOnSelections,
|
|
93
102
|
cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
|
|
94
103
|
promoCode: appliedPromoCode ?? null,
|
|
104
|
+
promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
|
|
95
105
|
newTotalAmount,
|
|
96
106
|
additionalHoursCount: null,
|
|
97
107
|
pricingAdjustment:
|
|
@@ -106,6 +116,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
106
116
|
: undefined,
|
|
107
117
|
authoritativeReceipt: providerApplyAuthoritativeReceipt,
|
|
108
118
|
structuredAdjustments: adminStructuredAdjustments,
|
|
119
|
+
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
109
120
|
refundDispositionsByOperationId,
|
|
110
121
|
pricingV2QuoteId: pricingV2QuoteId ?? null,
|
|
111
122
|
pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
|
|
@@ -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,11 +12,39 @@ 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[];
|
|
18
|
+
reverseAdjustmentIds?: string[];
|
|
16
19
|
refundDispositionsByOperationId?: Record<string, string>;
|
|
17
20
|
useAdminFeAuthoritativeQuote: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
interface AdminChangeQuoteRequestGateInput {
|
|
24
|
+
isCustomerSelfServeChange: boolean;
|
|
25
|
+
hasEffectiveChangeSelection: boolean;
|
|
26
|
+
hasSelectedAvailability: boolean;
|
|
27
|
+
bookingReference?: string | null;
|
|
28
|
+
lastName: string;
|
|
29
|
+
missingRequiredReturnSelection: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the current admin inputs are complete enough to request an authoritative quote.
|
|
34
|
+
* `hasEffectiveChangeSelection` deliberately includes adjustment-only changes, even when
|
|
35
|
+
* product, tickets, return, pickup, and itinerary are otherwise unchanged.
|
|
36
|
+
*/
|
|
37
|
+
export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInput): boolean {
|
|
38
|
+
return Boolean(
|
|
39
|
+
input.isCustomerSelfServeChange &&
|
|
40
|
+
input.hasEffectiveChangeSelection &&
|
|
41
|
+
input.hasSelectedAvailability &&
|
|
42
|
+
input.bookingReference?.trim() &&
|
|
43
|
+
input.lastName.trim() &&
|
|
44
|
+
!input.missingRequiredReturnSelection,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
/**
|
|
21
49
|
* Identity of a price request. This must contain user-controlled quote inputs only.
|
|
22
50
|
* Server/display-derived receipt state must never invalidate the response that produced it.
|
|
@@ -36,10 +64,22 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
36
64
|
addOnSelections: input.addOnSelections,
|
|
37
65
|
adminCustomReceiptLines: input.adminCustomReceiptLines,
|
|
38
66
|
cancellationPolicyId: input.cancellationPolicyId ?? null,
|
|
67
|
+
promoCode: input.promoCode?.trim().toUpperCase() ?? null,
|
|
68
|
+
promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
|
|
39
69
|
structuredAdjustments: input.structuredAdjustments ?? [],
|
|
70
|
+
reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
|
|
40
71
|
refundDispositionsByOperationId: Object.fromEntries(
|
|
41
72
|
Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
42
73
|
),
|
|
43
74
|
useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
|
|
44
75
|
});
|
|
45
76
|
}
|
|
77
|
+
|
|
78
|
+
/** A completed request is reusable only while its quote is still published in UI state. */
|
|
79
|
+
export function canReuseCompletedAdminChangeQuote(
|
|
80
|
+
inputKey: string,
|
|
81
|
+
lastCompletedInputKey: string | null,
|
|
82
|
+
hasPublishedQuote: boolean,
|
|
83
|
+
): boolean {
|
|
84
|
+
return Boolean(inputKey) && lastCompletedInputKey === inputKey && hasPublishedQuote;
|
|
85
|
+
}
|
|
@@ -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,109 @@
|
|
|
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
|
+
includeSelectableAdjustmentComponents?: boolean;
|
|
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: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
|
|
34
|
+
reverseAdjustmentIds?: string[];
|
|
35
|
+
includeSelectableAdjustmentComponents?: boolean;
|
|
36
|
+
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
37
|
+
previousPassengerCount: number;
|
|
38
|
+
previousAvailabilityId: string | null;
|
|
39
|
+
previousReturnAvailabilityId: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Canonical server-authoritative provider/admin quote request.
|
|
44
|
+
* Preview and checkout must use the same endpoint and request shape so checkout cannot
|
|
45
|
+
* replace a valid Pricing V2 quote with a legacy response that has no quote id.
|
|
46
|
+
*/
|
|
47
|
+
export function buildAdminChangeV2QuoteRequest({
|
|
48
|
+
bookingReference,
|
|
49
|
+
lastName,
|
|
50
|
+
parentProductId,
|
|
51
|
+
optionId,
|
|
52
|
+
selectedAvailability,
|
|
53
|
+
pickupLocationId,
|
|
54
|
+
returnAvailabilityId,
|
|
55
|
+
bookingItems,
|
|
56
|
+
addOnSelections,
|
|
57
|
+
cancellationPolicyId,
|
|
58
|
+
promoCode,
|
|
59
|
+
promoApplicationScope = 'CHANGE_ONLY',
|
|
60
|
+
structuredAdjustments,
|
|
61
|
+
reverseAdjustmentIds = [],
|
|
62
|
+
includeSelectableAdjustmentComponents = false,
|
|
63
|
+
refundDispositionsByOperationId,
|
|
64
|
+
previousPassengerCount,
|
|
65
|
+
previousAvailabilityId,
|
|
66
|
+
previousReturnAvailabilityId,
|
|
67
|
+
}: BuildAdminChangeV2QuoteRequestParams): AdminChangeV2QuoteRequest {
|
|
68
|
+
return {
|
|
69
|
+
bookingReference: bookingReference.trim(),
|
|
70
|
+
lastName: lastName.trim(),
|
|
71
|
+
newProductId: optionId,
|
|
72
|
+
newParentProductId: parentProductId,
|
|
73
|
+
newDateTime: selectedAvailability.dateTime,
|
|
74
|
+
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
75
|
+
newPickupLocationId: pickupLocationId || null,
|
|
76
|
+
newReturnAvailabilityId: returnAvailabilityId,
|
|
77
|
+
newPassengerCounts: bookingItems,
|
|
78
|
+
// Admin owns the complete target selection. [] intentionally clears all add-ons.
|
|
79
|
+
newAddOnSelections: addOnSelections,
|
|
80
|
+
cancellationPolicyId,
|
|
81
|
+
promoCode: promoCode?.trim() || null,
|
|
82
|
+
promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
|
|
83
|
+
structuredAdjustments,
|
|
84
|
+
reverseAdjustmentIds,
|
|
85
|
+
...(includeSelectableAdjustmentComponents
|
|
86
|
+
? { includeSelectableAdjustmentComponents: true }
|
|
87
|
+
: {}),
|
|
88
|
+
...(Object.keys(refundDispositionsByOperationId).length > 0
|
|
89
|
+
? { refundDispositionsByOperationId }
|
|
90
|
+
: {}),
|
|
91
|
+
capacitySeatCredit: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
previousPassengerCount,
|
|
94
|
+
previousAvailabilityId,
|
|
95
|
+
previousReturnAvailabilityId,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function quoteAdminChangeV2ForUi(
|
|
101
|
+
request: AdminChangeV2QuoteRequest,
|
|
102
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
103
|
+
currency: Currency,
|
|
104
|
+
quoteFn: (request: ChangeBookingQuoteRequest) => Promise<ChangeBookingQuoteResponse> =
|
|
105
|
+
quoteAdminChangeBookingV2,
|
|
106
|
+
): Promise<ChangeBookingMergedQuoteState> {
|
|
107
|
+
const quote = await quoteFn(request);
|
|
108
|
+
return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
|
|
109
|
+
}
|