@ticketboothapp/booking 1.2.167 → 1.2.169
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 +13 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
- package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
- package/src/components/booking/AdminChangeReceiptComparison.tsx +166 -145
- package/src/components/booking/ChangeBookingFlow.tsx +7 -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/PickupLocationSelector.module.css +22 -0
- package/src/components/booking/PickupLocationSelector.tsx +3 -3
- package/src/components/booking/PriceSummary.tsx +4 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
- 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 +3 -0
- package/src/components/booking/admin-change-quote-request-key.ts +27 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
- package/src/components/booking/admin-refund-decision-context.ts +37 -0
- package/src/components/booking/admin-refund-disposition.ts +71 -2
- 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 +16 -1
- package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
- package/src/components/booking/useAdminChangeProductReset.ts +6 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
- package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
- package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
- package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
- 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/lib/booking/change-booking-server-preview.ts +26 -9
- 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 +64 -0
- package/test/change-booking-helpers.test.ts +853 -3
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
|
@@ -11,15 +11,12 @@ import type { Currency } from './CurrencySwitcher';
|
|
|
11
11
|
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
12
12
|
import { collapseAdminPromoLines } from './admin-change-receipt-lines';
|
|
13
13
|
import {
|
|
14
|
-
adminRemovalOperationLabel,
|
|
15
14
|
noRefundRemovedValue,
|
|
16
|
-
normalizeReturnPriceTreatment,
|
|
17
15
|
refundDecisionsComplete,
|
|
18
|
-
releaseRefundDispositionOptions,
|
|
19
|
-
returnPriceTreatmentOptions,
|
|
20
16
|
type AdminReleaseRefundDisposition,
|
|
21
|
-
type AdminReturnPriceTreatment,
|
|
22
17
|
} from './admin-refund-disposition';
|
|
18
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
19
|
+
import { isProductFamilyReceiptConversion } from './change-booking-flow-helpers';
|
|
23
20
|
|
|
24
21
|
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
25
22
|
type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
|
|
@@ -28,13 +25,22 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
28
25
|
originalReceipt: OriginalReceipt;
|
|
29
26
|
/** Amendment-only rows returned by the Pricing V2 server quote. */
|
|
30
27
|
amendmentLines: PriceSummaryLine[] | null;
|
|
28
|
+
/** Full receipt after the amendment, used for clear family-conversion comparisons. */
|
|
29
|
+
resultingReceiptLines?: PriceSummaryLine[] | null;
|
|
31
30
|
/** Signed server balance delta. Never calculated by this component. */
|
|
32
31
|
amountDue: number | null;
|
|
33
32
|
/** Full resulting booking total returned by the same server quote. */
|
|
34
33
|
updatedTotal: number | null;
|
|
34
|
+
/** Settled cash after completed refunds, calculated by the backend. */
|
|
35
|
+
netPaid?: number | null;
|
|
36
|
+
currentPaymentCredit?: number;
|
|
37
|
+
projectedPaymentCredit?: number;
|
|
38
|
+
refundCandidate?: number;
|
|
35
39
|
selectionChanged: boolean;
|
|
36
40
|
quoteError?: string | null;
|
|
37
41
|
amountDueLabel: string;
|
|
42
|
+
/** Whether to show the detailed post-change settlement summary above the amount-due bar. */
|
|
43
|
+
showAfterChangeSummary?: boolean;
|
|
38
44
|
currency: Currency;
|
|
39
45
|
locale: string;
|
|
40
46
|
t: TranslationFn;
|
|
@@ -52,6 +58,8 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
52
58
|
disposition: AdminReleaseRefundDisposition | null,
|
|
53
59
|
) => void;
|
|
54
60
|
refundQuoteLoading?: boolean;
|
|
61
|
+
sourceProductName?: string | null;
|
|
62
|
+
targetProductName?: string | null;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
function normalizeLocale(locale: string): Locale {
|
|
@@ -82,11 +90,17 @@ function amendmentLineOrder(line: PriceSummaryLine): number {
|
|
|
82
90
|
export function AdminChangeReceiptComparison({
|
|
83
91
|
originalReceipt,
|
|
84
92
|
amendmentLines,
|
|
93
|
+
resultingReceiptLines = null,
|
|
85
94
|
amountDue,
|
|
86
95
|
updatedTotal,
|
|
96
|
+
netPaid,
|
|
97
|
+
currentPaymentCredit = 0,
|
|
98
|
+
projectedPaymentCredit = 0,
|
|
99
|
+
refundCandidate = 0,
|
|
87
100
|
selectionChanged,
|
|
88
101
|
quoteError,
|
|
89
102
|
amountDueLabel,
|
|
103
|
+
showAfterChangeSummary = true,
|
|
90
104
|
currency,
|
|
91
105
|
locale,
|
|
92
106
|
t,
|
|
@@ -95,30 +109,47 @@ export function AdminChangeReceiptComparison({
|
|
|
95
109
|
promoEditor,
|
|
96
110
|
promoCode,
|
|
97
111
|
refundDecisionOperations = [],
|
|
98
|
-
returnPriceTreatmentOperations = [],
|
|
99
|
-
allowedRefundDispositionsByOperationId = {},
|
|
100
112
|
removedValueByOperationId = {},
|
|
101
113
|
refundDispositionsByOperationId = {},
|
|
102
|
-
onRefundDispositionChange,
|
|
103
114
|
refundQuoteLoading = false,
|
|
104
115
|
}: AdminChangeReceiptComparisonProps) {
|
|
116
|
+
const readableQuoteError = friendlyChangeBookingError(quoteError);
|
|
105
117
|
const displayLocale = normalizeLocale(locale);
|
|
106
118
|
const existingLines = originalLines(originalReceipt);
|
|
107
119
|
const originalCurrency = originalReceipt.currency ?? currency;
|
|
108
120
|
const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
|
|
121
|
+
const settlementReady = quoteReady && netPaid != null;
|
|
122
|
+
const currentBalanceOwing = settlementReady
|
|
123
|
+
? Math.max(
|
|
124
|
+
0,
|
|
125
|
+
Math.round(
|
|
126
|
+
(originalReceipt.total - netPaid - currentPaymentCredit) * 100,
|
|
127
|
+
) / 100,
|
|
128
|
+
)
|
|
129
|
+
: null;
|
|
130
|
+
const projectedBalanceOwing = settlementReady
|
|
131
|
+
? Math.max(
|
|
132
|
+
0,
|
|
133
|
+
Math.round(
|
|
134
|
+
(updatedTotal - netPaid - projectedPaymentCredit) * 100,
|
|
135
|
+
) / 100,
|
|
136
|
+
)
|
|
137
|
+
: null;
|
|
138
|
+
const balanceReduction = settlementReady
|
|
139
|
+
? Math.max(
|
|
140
|
+
0,
|
|
141
|
+
Math.round(((currentBalanceOwing ?? 0) - (projectedBalanceOwing ?? 0)) * 100) / 100,
|
|
142
|
+
)
|
|
143
|
+
: 0;
|
|
109
144
|
const hasRefundDecisions = refundDecisionOperations.length > 0;
|
|
110
|
-
const hasReturnPriceTreatments = returnPriceTreatmentOperations.length > 0;
|
|
111
145
|
const refundSelectionsComplete = refundDecisionsComplete(
|
|
112
146
|
refundDecisionOperations,
|
|
113
147
|
refundDispositionsByOperationId,
|
|
114
148
|
);
|
|
115
149
|
const settlementPending = hasRefundDecisions && (!refundSelectionsComplete || refundQuoteLoading);
|
|
116
|
-
const returnRepricePending = hasReturnPriceTreatments &&
|
|
117
|
-
returnPriceTreatmentOperations.some(
|
|
118
|
-
(operation) => refundDispositionsByOperationId[operation.operationId] != null,
|
|
119
|
-
) &&
|
|
120
|
-
refundQuoteLoading;
|
|
121
150
|
const displayedAmountDue = settlementPending ? null : amountDue;
|
|
151
|
+
const bookingTotalChange =
|
|
152
|
+
updatedTotal == null ? null : Math.round((updatedTotal - originalReceipt.total) * 100) / 100;
|
|
122
153
|
const removedWithoutRefund = noRefundRemovedValue(
|
|
123
154
|
refundDecisionOperations,
|
|
124
155
|
refundDispositionsByOperationId,
|
|
@@ -151,13 +182,42 @@ export function AdminChangeReceiptComparison({
|
|
|
151
182
|
...(Math.abs(amendmentTax) >= 0.005
|
|
152
183
|
? [{
|
|
153
184
|
kind: 'line' as const,
|
|
154
|
-
label: amendmentTax < 0 ? '
|
|
185
|
+
label: amendmentTax < 0 ? 'Tax reduction' : 'Additional tax',
|
|
155
186
|
amount: amendmentTax,
|
|
156
187
|
type: 'TAX',
|
|
157
188
|
}]
|
|
158
189
|
: []),
|
|
159
190
|
]
|
|
160
191
|
: [];
|
|
192
|
+
const familyConversionReceiptLines = collapseAdminPromoLines(
|
|
193
|
+
resultingReceiptLines ?? [],
|
|
194
|
+
promoCode,
|
|
195
|
+
);
|
|
196
|
+
const isProductFamilyConversion = isProductFamilyReceiptConversion(
|
|
197
|
+
existingLines,
|
|
198
|
+
familyConversionReceiptLines,
|
|
199
|
+
);
|
|
200
|
+
const summaryDisplayLines = isProductFamilyConversion
|
|
201
|
+
? familyConversionReceiptLines
|
|
202
|
+
: amendmentDisplayLines;
|
|
203
|
+
const summarySubtotal = isProductFamilyConversion
|
|
204
|
+
? familyConversionReceiptLines.reduce((sum, line) => {
|
|
205
|
+
if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
|
|
206
|
+
return sum + (line.kind === 'ticket' ? line.itemTotal : line.amount);
|
|
207
|
+
}, 0)
|
|
208
|
+
: amendmentSubtotal;
|
|
209
|
+
const summaryTotal = isProductFamilyConversion
|
|
210
|
+
? updatedTotal
|
|
211
|
+
: bookingTotalChange ?? amountDue;
|
|
212
|
+
const translatedChangeTotalLabel = t('booking.changeToBookingTotal');
|
|
213
|
+
const summaryTotalLabel = isProductFamilyConversion
|
|
214
|
+
? 'New booking total'
|
|
215
|
+
: translatedChangeTotalLabel && translatedChangeTotalLabel !== 'booking.changeToBookingTotal'
|
|
216
|
+
? translatedChangeTotalLabel
|
|
217
|
+
: 'Change to booking total';
|
|
218
|
+
const displayedSettlementAmount = refundCandidate > 0.005
|
|
219
|
+
? refundCandidate
|
|
220
|
+
: displayedAmountDue;
|
|
161
221
|
const amountDueClass =
|
|
162
222
|
displayedAmountDue != null && displayedAmountDue < -0.005
|
|
163
223
|
? 'text-emerald-700'
|
|
@@ -167,107 +227,6 @@ export function AdminChangeReceiptComparison({
|
|
|
167
227
|
|
|
168
228
|
return (
|
|
169
229
|
<div className="min-w-0 space-y-3 overflow-visible">
|
|
170
|
-
{hasReturnPriceTreatments ? (
|
|
171
|
-
<section className="rounded-lg border border-sky-300 bg-sky-50 p-3" aria-label="Return price treatment">
|
|
172
|
-
<div className="mb-3">
|
|
173
|
-
<h3 className="text-sm font-semibold text-stone-900">Return price</h3>
|
|
174
|
-
<p className="mt-1 text-xs text-stone-600">
|
|
175
|
-
By default the original paid return price stays on the booking. You can optionally reduce it and choose refund treatment.
|
|
176
|
-
</p>
|
|
177
|
-
</div>
|
|
178
|
-
<div className="space-y-3">
|
|
179
|
-
{returnPriceTreatmentOperations.map((operation) => {
|
|
180
|
-
const options = returnPriceTreatmentOptions(
|
|
181
|
-
allowedRefundDispositionsByOperationId[operation.operationId],
|
|
182
|
-
);
|
|
183
|
-
const selectedValue: AdminReturnPriceTreatment = normalizeReturnPriceTreatment(
|
|
184
|
-
refundDispositionsByOperationId[operation.operationId],
|
|
185
|
-
);
|
|
186
|
-
const selected = options.find((option) => option.value === selectedValue);
|
|
187
|
-
return (
|
|
188
|
-
<div key={operation.operationId} className="rounded-md border border-sky-200 bg-white p-3">
|
|
189
|
-
<div className="mb-2 text-sm font-medium capitalize text-stone-900">
|
|
190
|
-
{adminRemovalOperationLabel(operation)}
|
|
191
|
-
</div>
|
|
192
|
-
<select
|
|
193
|
-
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
|
|
194
|
-
value={selectedValue}
|
|
195
|
-
onChange={(event) => {
|
|
196
|
-
const value = event.target.value as AdminReturnPriceTreatment;
|
|
197
|
-
onRefundDispositionChange?.(
|
|
198
|
-
operation.operationId,
|
|
199
|
-
value === 'KEEP_FLOOR' ? null : value,
|
|
200
|
-
);
|
|
201
|
-
}}
|
|
202
|
-
aria-label={`Return price treatment for ${adminRemovalOperationLabel(operation)}`}
|
|
203
|
-
>
|
|
204
|
-
{options.map((option) => (
|
|
205
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
206
|
-
))}
|
|
207
|
-
</select>
|
|
208
|
-
{selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
|
|
209
|
-
</div>
|
|
210
|
-
);
|
|
211
|
-
})}
|
|
212
|
-
</div>
|
|
213
|
-
{returnRepricePending ? (
|
|
214
|
-
<p className="mt-3 text-xs font-medium text-sky-800">Recalculating settlement…</p>
|
|
215
|
-
) : null}
|
|
216
|
-
</section>
|
|
217
|
-
) : null}
|
|
218
|
-
|
|
219
|
-
{hasRefundDecisions ? (
|
|
220
|
-
<section className="rounded-lg border border-amber-300 bg-amber-50 p-3" aria-label="Refund treatment">
|
|
221
|
-
<div className="mb-3">
|
|
222
|
-
<h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
|
|
223
|
-
<p className="mt-1 text-xs text-stone-600">
|
|
224
|
-
Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
|
|
225
|
-
</p>
|
|
226
|
-
</div>
|
|
227
|
-
<div className="space-y-3">
|
|
228
|
-
{refundDecisionOperations.map((operation) => {
|
|
229
|
-
const options = releaseRefundDispositionOptions(
|
|
230
|
-
allowedRefundDispositionsByOperationId[operation.operationId],
|
|
231
|
-
);
|
|
232
|
-
const selectedValue = refundDispositionsByOperationId[operation.operationId];
|
|
233
|
-
const selected = options.find((option) => option.value === selectedValue);
|
|
234
|
-
const removedValue = removedValueByOperationId[operation.operationId];
|
|
235
|
-
return (
|
|
236
|
-
<div key={operation.operationId} className="rounded-md border border-amber-200 bg-white p-3">
|
|
237
|
-
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 text-sm font-medium text-stone-900">
|
|
238
|
-
<span className="capitalize">{adminRemovalOperationLabel(operation)}</span>
|
|
239
|
-
{Number.isFinite(removedValue) ? (
|
|
240
|
-
<span>{formatCurrencyAmount(removedValue, currency, displayLocale)}</span>
|
|
241
|
-
) : null}
|
|
242
|
-
</div>
|
|
243
|
-
<select
|
|
244
|
-
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
|
|
245
|
-
value={selectedValue ?? ''}
|
|
246
|
-
onChange={(event) => {
|
|
247
|
-
const value = event.target.value;
|
|
248
|
-
onRefundDispositionChange?.(
|
|
249
|
-
operation.operationId,
|
|
250
|
-
value ? value as AdminReleaseRefundDisposition : null,
|
|
251
|
-
);
|
|
252
|
-
}}
|
|
253
|
-
aria-label={`Refund treatment for ${adminRemovalOperationLabel(operation)}`}
|
|
254
|
-
>
|
|
255
|
-
<option value="">Choose refund treatment…</option>
|
|
256
|
-
{options.map((option) => (
|
|
257
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
258
|
-
))}
|
|
259
|
-
</select>
|
|
260
|
-
{selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
|
|
261
|
-
</div>
|
|
262
|
-
);
|
|
263
|
-
})}
|
|
264
|
-
</div>
|
|
265
|
-
{refundQuoteLoading && refundSelectionsComplete ? (
|
|
266
|
-
<p className="mt-3 text-xs font-medium text-amber-800">Recalculating settlement…</p>
|
|
267
|
-
) : null}
|
|
268
|
-
</section>
|
|
269
|
-
) : null}
|
|
270
|
-
|
|
271
230
|
<div className="grid gap-4 md:grid-cols-2">
|
|
272
231
|
<section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
|
|
273
232
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
@@ -292,11 +251,21 @@ export function AdminChangeReceiptComparison({
|
|
|
292
251
|
<section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
|
|
293
252
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
294
253
|
<div>
|
|
295
|
-
<h3 className="text-sm font-semibold text-stone-900">
|
|
296
|
-
|
|
254
|
+
<h3 className="text-sm font-semibold text-stone-900">
|
|
255
|
+
{isProductFamilyConversion ? 'New purchase' : 'Current change'}
|
|
256
|
+
</h3>
|
|
257
|
+
{isProductFamilyConversion ? (
|
|
258
|
+
<p className="mt-0.5 text-xs text-stone-500">
|
|
259
|
+
Server-priced booking after conversion
|
|
260
|
+
</p>
|
|
261
|
+
) : null}
|
|
297
262
|
</div>
|
|
298
263
|
<span className="shrink-0 text-xs font-medium text-stone-500">
|
|
299
|
-
{
|
|
264
|
+
{isProductFamilyConversion
|
|
265
|
+
? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
|
|
266
|
+
: bookingTotalChange == null
|
|
267
|
+
? '—'
|
|
268
|
+
: formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
|
|
300
269
|
</span>
|
|
301
270
|
</div>
|
|
302
271
|
{promoEditor}
|
|
@@ -305,37 +274,38 @@ export function AdminChangeReceiptComparison({
|
|
|
305
274
|
<p className="text-sm font-medium text-stone-700">No changes selected</p>
|
|
306
275
|
<p className="mt-1 text-xs text-stone-500">Change tickets or another booking option to request a price.</p>
|
|
307
276
|
</div>
|
|
308
|
-
) :
|
|
277
|
+
) : readableQuoteError ? (
|
|
309
278
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-4 text-center" role="alert">
|
|
310
|
-
<p className="text-sm font-medium text-red-800">{
|
|
279
|
+
<p className="text-sm font-medium text-red-800">{readableQuoteError}</p>
|
|
311
280
|
</div>
|
|
312
281
|
) : !quoteReady ? (
|
|
313
282
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
314
|
-
<p className="text-sm font-medium text-stone-700">
|
|
283
|
+
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
315
284
|
</div>
|
|
316
285
|
) : settlementPending ? (
|
|
317
|
-
<div className="rounded-md border border-dashed border-
|
|
318
|
-
<p className="text-sm font-medium text-
|
|
319
|
-
<p className="mt-1 text-xs text-stone-600">Choose how to handle the removed paid value above.</p>
|
|
286
|
+
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
287
|
+
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
320
288
|
</div>
|
|
321
|
-
) :
|
|
289
|
+
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
322
290
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
323
291
|
<p className="text-sm font-medium text-stone-800">No refund will be created</p>
|
|
324
292
|
<p className="mt-1 text-xs text-stone-600">
|
|
325
293
|
{formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
|
|
326
294
|
</p>
|
|
327
295
|
</div>
|
|
328
|
-
) :
|
|
296
|
+
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
329
297
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
330
298
|
<p className="text-sm font-medium text-stone-700">No pricing changes</p>
|
|
331
299
|
</div>
|
|
332
300
|
) : (
|
|
333
301
|
<PriceSummary
|
|
334
|
-
lines={
|
|
335
|
-
total={amountDue}
|
|
302
|
+
lines={summaryDisplayLines}
|
|
303
|
+
total={summaryTotal ?? amountDue}
|
|
304
|
+
totalLabel={summaryTotalLabel}
|
|
305
|
+
showPositiveTotalSign={!isProductFamilyConversion}
|
|
336
306
|
currency={currency}
|
|
337
307
|
locale={displayLocale}
|
|
338
|
-
subtotal={
|
|
308
|
+
subtotal={summarySubtotal}
|
|
339
309
|
size="sm"
|
|
340
310
|
t={t}
|
|
341
311
|
/>
|
|
@@ -343,31 +313,82 @@ export function AdminChangeReceiptComparison({
|
|
|
343
313
|
</section>
|
|
344
314
|
</div>
|
|
345
315
|
|
|
346
|
-
{quoteReady ? (
|
|
347
|
-
<section className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
|
|
348
|
-
<div>
|
|
349
|
-
<h3 className="text-sm font-semibold text-stone-900">Updated booking total</h3>
|
|
350
|
-
<p className="mt-0.5 text-xs text-stone-500">Authoritative total returned by the server quote</p>
|
|
351
|
-
</div>
|
|
352
|
-
<span className="text-lg font-semibold tabular-nums text-stone-900">
|
|
353
|
-
{formatCurrencyAmount(updatedTotal, currency, displayLocale)}
|
|
354
|
-
</span>
|
|
355
|
-
</section>
|
|
356
|
-
) : null}
|
|
357
|
-
|
|
358
316
|
{adjustments ? (
|
|
359
317
|
<section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
|
|
360
|
-
<h3 className="mb-2 text-sm font-semibold text-stone-900">
|
|
318
|
+
<h3 className="mb-2 text-sm font-semibold text-stone-900">Add custom line items</h3>
|
|
361
319
|
{adjustments}
|
|
362
320
|
</section>
|
|
363
321
|
) : null}
|
|
364
322
|
|
|
323
|
+
{quoteReady && showAfterChangeSummary ? (
|
|
324
|
+
<section className="rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
|
|
325
|
+
<h3 className="text-sm font-semibold text-stone-900">After this change</h3>
|
|
326
|
+
{settlementReady ? (
|
|
327
|
+
<div className="mt-3 space-y-2 border-t border-stone-200 pt-3 text-xs">
|
|
328
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
329
|
+
<span>Updated booking total</span>
|
|
330
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
331
|
+
{formatCurrencyAmount(updatedTotal, currency, displayLocale)}
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
335
|
+
<span>Customer already paid</span>
|
|
336
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
337
|
+
{formatCurrencyAmount(netPaid, currency, displayLocale)}
|
|
338
|
+
</span>
|
|
339
|
+
</div>
|
|
340
|
+
{projectedPaymentCredit > 0.005 ? (
|
|
341
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
342
|
+
<span>Payment credit applied</span>
|
|
343
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
344
|
+
{formatCurrencyAmount(projectedPaymentCredit, currency, displayLocale)}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
) : null}
|
|
348
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
349
|
+
<span>Balance owing before change</span>
|
|
350
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
351
|
+
{formatCurrencyAmount(currentBalanceOwing ?? 0, currency, displayLocale)}
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
{balanceReduction > 0.005 ? (
|
|
355
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
|
|
356
|
+
<span>
|
|
357
|
+
{isProductFamilyConversion
|
|
358
|
+
? 'Previous unpaid balance removed'
|
|
359
|
+
: 'Balance reduction'}
|
|
360
|
+
</span>
|
|
361
|
+
<span className="tabular-nums">
|
|
362
|
+
{formatCurrencyAmount(balanceReduction, currency, displayLocale)}
|
|
363
|
+
</span>
|
|
364
|
+
</div>
|
|
365
|
+
) : null}
|
|
366
|
+
{refundCandidate > 0.005 ? (
|
|
367
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
|
|
368
|
+
<span>Amount to refund customer</span>
|
|
369
|
+
<span className="tabular-nums">
|
|
370
|
+
{formatCurrencyAmount(refundCandidate, currency, displayLocale)}
|
|
371
|
+
</span>
|
|
372
|
+
</div>
|
|
373
|
+
) : amountDue < -0.005 ? (
|
|
374
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-stone-600">
|
|
375
|
+
<span>Amount to refund customer</span>
|
|
376
|
+
<span className="tabular-nums">
|
|
377
|
+
{formatCurrencyAmount(0, currency, displayLocale)}
|
|
378
|
+
</span>
|
|
379
|
+
</div>
|
|
380
|
+
) : null}
|
|
381
|
+
</div>
|
|
382
|
+
) : null}
|
|
383
|
+
</section>
|
|
384
|
+
) : null}
|
|
385
|
+
|
|
365
386
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
|
366
387
|
<span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
|
|
367
388
|
<span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
|
|
368
|
-
{!selectionChanged ||
|
|
389
|
+
{!selectionChanged || displayedSettlementAmount == null
|
|
369
390
|
? '—'
|
|
370
|
-
: formatCurrencyAmount(
|
|
391
|
+
: formatCurrencyAmount(displayedSettlementAmount, currency, displayLocale)}
|
|
371
392
|
</span>
|
|
372
393
|
</div>
|
|
373
394
|
</div>
|
|
@@ -149,7 +149,7 @@ export function ChangeBookingFlow({
|
|
|
149
149
|
useSeedPromoCodeInput(autoAppliedPromoCode, setPromoCodeInput);
|
|
150
150
|
const [loading, setLoading] = useState(false);
|
|
151
151
|
const [error, setError] = useState('');
|
|
152
|
-
const [termsAccepted, setTermsAccepted] = useState(
|
|
152
|
+
const [termsAccepted, setTermsAccepted] = useState(true);
|
|
153
153
|
const [, setTermsAcceptedAt] = useState<string | null>(null);
|
|
154
154
|
const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
|
|
155
155
|
const changeFlowOriginalDate = useMemo(
|
|
@@ -304,6 +304,8 @@ export function ChangeBookingFlow({
|
|
|
304
304
|
timesForSelectedDate,
|
|
305
305
|
companyTimezone,
|
|
306
306
|
changeFlowResolvedInitialProductOptionId,
|
|
307
|
+
mostPopularProductOptionId:
|
|
308
|
+
activeOptions.find((option) => option.mostPopular)?.optionId ?? null,
|
|
307
309
|
precomputedPricesByOption,
|
|
308
310
|
currency,
|
|
309
311
|
originalReceiptSubtotal: originalReceipt?.subtotal,
|
|
@@ -325,6 +327,7 @@ export function ChangeBookingFlow({
|
|
|
325
327
|
});
|
|
326
328
|
|
|
327
329
|
const {
|
|
330
|
+
initialAddOnQtyByKey,
|
|
328
331
|
initialAddOnMinQtyByKey,
|
|
329
332
|
initialAddOnMinTotalByAddOnId,
|
|
330
333
|
updateAddOnSelections,
|
|
@@ -332,7 +335,7 @@ export function ChangeBookingFlow({
|
|
|
332
335
|
initialValues,
|
|
333
336
|
originalReceiptLineItems: originalReceipt?.lineItems,
|
|
334
337
|
addOns,
|
|
335
|
-
isCustomerSelfServeChange,
|
|
338
|
+
lockExistingAddOnQuantities: isCustomerSelfServeChange,
|
|
336
339
|
setAddOnSelections,
|
|
337
340
|
});
|
|
338
341
|
|
|
@@ -571,7 +574,7 @@ export function ChangeBookingFlow({
|
|
|
571
574
|
pickupLocationId,
|
|
572
575
|
quantities,
|
|
573
576
|
addOnSelections,
|
|
574
|
-
|
|
577
|
+
initialAddOnQtyByKey,
|
|
575
578
|
changeFlowResolvedInitialProductOptionId,
|
|
576
579
|
});
|
|
577
580
|
const hasChangeSelection = changeSelectionDetails.hasChangesFromInitial;
|
|
@@ -609,6 +612,7 @@ export function ChangeBookingFlow({
|
|
|
609
612
|
selectionChanged={hasEffectiveChangeSelection}
|
|
610
613
|
quoteError={changeQuoteFetchError}
|
|
611
614
|
amountDueLabel="Amount due now"
|
|
615
|
+
showAfterChangeSummary={false}
|
|
612
616
|
currency={currency}
|
|
613
617
|
locale={locale}
|
|
614
618
|
t={t}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
2
3
|
|
|
3
4
|
export interface ChangeBookingQuoteStatusPlaceholderParams {
|
|
4
5
|
suppressSelfServeCurrencyUi: boolean;
|
|
@@ -37,14 +38,18 @@ export function buildChangeBookingQuoteStatusPlaceholder({
|
|
|
37
38
|
if (changeQuoteFetchError) {
|
|
38
39
|
return (
|
|
39
40
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
|
40
|
-
{changeQuoteFetchError}
|
|
41
|
+
{friendlyChangeBookingError(changeQuoteFetchError)}
|
|
41
42
|
</div>
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
if (quoteCanProceed === false) {
|
|
45
46
|
return (
|
|
46
47
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
|
47
|
-
<div>
|
|
48
|
+
<div>
|
|
49
|
+
{friendlyChangeBookingError(
|
|
50
|
+
quoteBlockedReason ?? 'This booking change is not available.',
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
48
53
|
{blockedDetails}
|
|
49
54
|
</div>
|
|
50
55
|
);
|
|
@@ -42,6 +42,8 @@ export interface ChangeBookingSelectionControlsPanelProps {
|
|
|
42
42
|
applyReceiptPaidFloors: boolean;
|
|
43
43
|
ticketUnitFloorByCategory?: Map<string, number>;
|
|
44
44
|
addOns: AddOn[];
|
|
45
|
+
knownAddOns?: AddOn[];
|
|
46
|
+
addOnCatalogLoaded?: boolean;
|
|
45
47
|
addOnSelections: AddOnSelection[];
|
|
46
48
|
onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
|
|
47
49
|
minimumTotalByAddOnId?: Map<string, number>;
|
|
@@ -80,6 +82,8 @@ export function ChangeBookingSelectionControlsPanel({
|
|
|
80
82
|
applyReceiptPaidFloors,
|
|
81
83
|
ticketUnitFloorByCategory,
|
|
82
84
|
addOns,
|
|
85
|
+
knownAddOns,
|
|
86
|
+
addOnCatalogLoaded,
|
|
83
87
|
addOnSelections,
|
|
84
88
|
onAddOnSelectionsChange,
|
|
85
89
|
minimumTotalByAddOnId,
|
|
@@ -146,6 +150,8 @@ export function ChangeBookingSelectionControlsPanel({
|
|
|
146
150
|
ticketUnitFloorByCategory={ticketUnitFloorByCategory}
|
|
147
151
|
suppressUnitPrices={suppressUnitPrices}
|
|
148
152
|
addOns={addOns}
|
|
153
|
+
knownAddOns={knownAddOns}
|
|
154
|
+
addOnCatalogLoaded={addOnCatalogLoaded}
|
|
149
155
|
addOnSelections={addOnSelections}
|
|
150
156
|
onAddOnSelectionsChange={onAddOnSelectionsChange}
|
|
151
157
|
minimumTotalByAddOnId={minimumTotalByAddOnId}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { AddOn, Availability, ReturnOption } from '../../lib/booking-api';
|
|
2
2
|
import { AddOnsSection, type AddOnSelection } from './AddOnsSection';
|
|
3
3
|
import type { Currency } from './CurrencySwitcher';
|
|
4
|
+
import {
|
|
5
|
+
incompatibleAddOnSelections,
|
|
6
|
+
removeAddOnSelections,
|
|
7
|
+
} from './incompatible-add-on-selections';
|
|
4
8
|
import { TicketSelector, type TicketRate } from './TicketSelector';
|
|
5
9
|
|
|
6
10
|
type TranslationFn = (key: string, params?: Record<string, string | number>) => string;
|
|
@@ -29,6 +33,8 @@ export interface ChangeBookingTicketsAndAddOnsPanelProps {
|
|
|
29
33
|
applyReceiptPaidFloors: boolean;
|
|
30
34
|
ticketUnitFloorByCategory?: Map<string, number>;
|
|
31
35
|
addOns: AddOn[];
|
|
36
|
+
knownAddOns?: AddOn[];
|
|
37
|
+
addOnCatalogLoaded?: boolean;
|
|
32
38
|
addOnSelections: AddOnSelection[];
|
|
33
39
|
onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
|
|
34
40
|
minimumTotalByAddOnId?: Map<string, number>;
|
|
@@ -56,12 +62,23 @@ export function ChangeBookingTicketsAndAddOnsPanel({
|
|
|
56
62
|
applyReceiptPaidFloors,
|
|
57
63
|
ticketUnitFloorByCategory,
|
|
58
64
|
addOns,
|
|
65
|
+
knownAddOns = addOns,
|
|
66
|
+
addOnCatalogLoaded = false,
|
|
59
67
|
addOnSelections,
|
|
60
68
|
onAddOnSelectionsChange,
|
|
61
69
|
minimumTotalByAddOnId,
|
|
62
70
|
suppressUnitPrices = false,
|
|
63
71
|
suppressAddOnPrices = false,
|
|
64
72
|
}: ChangeBookingTicketsAndAddOnsPanelProps) {
|
|
73
|
+
const incompatibleSelections = incompatibleAddOnSelections(
|
|
74
|
+
addOnSelections,
|
|
75
|
+
addOns,
|
|
76
|
+
addOnCatalogLoaded,
|
|
77
|
+
);
|
|
78
|
+
const incompatibleAddOnIds = Array.from(
|
|
79
|
+
new Set(incompatibleSelections.map((selection) => selection.addOnId)),
|
|
80
|
+
);
|
|
81
|
+
|
|
65
82
|
return (
|
|
66
83
|
<>
|
|
67
84
|
<TicketSelector
|
|
@@ -97,6 +114,69 @@ export function ChangeBookingTicketsAndAddOnsPanel({
|
|
|
97
114
|
suppressPrices={suppressAddOnPrices}
|
|
98
115
|
/>
|
|
99
116
|
) : null}
|
|
117
|
+
|
|
118
|
+
{incompatibleAddOnIds.length > 0 ? (
|
|
119
|
+
<div
|
|
120
|
+
className="space-y-3 rounded-lg border border-red-300 bg-red-50 p-4"
|
|
121
|
+
data-testid="incompatible-add-ons"
|
|
122
|
+
>
|
|
123
|
+
<div>
|
|
124
|
+
<h3 className="text-sm font-semibold !text-red-700">
|
|
125
|
+
Add-on unavailable with selected tour
|
|
126
|
+
</h3>
|
|
127
|
+
<p className="mt-1 text-sm text-red-800">
|
|
128
|
+
This booking still contains an add-on that the selected tour does not offer.
|
|
129
|
+
Remove it explicitly before recalculating the amendment.
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
{incompatibleAddOnIds.map((addOnId) => {
|
|
133
|
+
const addOn = knownAddOns.find((candidate) => candidate.addOnId === addOnId);
|
|
134
|
+
const selections = incompatibleSelections.filter(
|
|
135
|
+
(selection) => selection.addOnId === addOnId,
|
|
136
|
+
);
|
|
137
|
+
const quantity = selections.reduce(
|
|
138
|
+
(total, selection) => total + Math.max(1, Number(selection.quantity) || 1),
|
|
139
|
+
0,
|
|
140
|
+
);
|
|
141
|
+
const variants = selections
|
|
142
|
+
.map((selection) => (
|
|
143
|
+
addOn?.variants.find((variant) => variant.id === selection.variantId)?.label ??
|
|
144
|
+
(selection.variantId ? 'Selected option' : null)
|
|
145
|
+
))
|
|
146
|
+
.filter((label): label is string => Boolean(label));
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
key={addOnId}
|
|
150
|
+
className="flex flex-col gap-3 rounded-md border border-red-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
151
|
+
>
|
|
152
|
+
<div className="text-sm">
|
|
153
|
+
<div className="font-medium text-stone-900">{addOn?.name ?? 'Selected add-on'}</div>
|
|
154
|
+
<div className="text-stone-600">
|
|
155
|
+
{variants.length > 0 ? variants.join(', ') : `Quantity ${quantity}`}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
{isAdmin ? (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className="rounded-md border border-red-500 bg-white px-3 py-2 text-sm font-semibold text-red-800 hover:bg-red-100"
|
|
162
|
+
onClick={() => {
|
|
163
|
+
onAddOnSelectionsChange((current) =>
|
|
164
|
+
removeAddOnSelections(current, addOnId),
|
|
165
|
+
);
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
Remove add-on
|
|
169
|
+
</button>
|
|
170
|
+
) : (
|
|
171
|
+
<p className="text-sm text-red-800">
|
|
172
|
+
Choose another tour to keep this add-on.
|
|
173
|
+
</p>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
100
180
|
</>
|
|
101
181
|
);
|
|
102
182
|
}
|