@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,97 @@
|
|
|
1
|
+
import type { AdminPromoApplicationScope } from '../../lib/booking-api';
|
|
2
|
+
import type { Currency } from './CurrencySwitcher';
|
|
3
|
+
import { PromoCodeInput } from './PromoCodeInput';
|
|
4
|
+
|
|
5
|
+
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
6
|
+
|
|
7
|
+
interface AdminChangePromoEditorProps {
|
|
8
|
+
previousPromoCode: string | null;
|
|
9
|
+
promoCodeInput: string;
|
|
10
|
+
appliedPromoCode: string | null;
|
|
11
|
+
promoCodeError: string;
|
|
12
|
+
promoCodeValidating: boolean;
|
|
13
|
+
scope: AdminPromoApplicationScope;
|
|
14
|
+
currency: Currency;
|
|
15
|
+
locale: string;
|
|
16
|
+
t: TranslationFn;
|
|
17
|
+
onInputChange: (value: string) => void;
|
|
18
|
+
onApply: () => void;
|
|
19
|
+
onRemove: () => void;
|
|
20
|
+
onScopeChange: (scope: AdminPromoApplicationScope) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AdminChangePromoEditor({
|
|
24
|
+
previousPromoCode,
|
|
25
|
+
promoCodeInput,
|
|
26
|
+
appliedPromoCode,
|
|
27
|
+
promoCodeError,
|
|
28
|
+
promoCodeValidating,
|
|
29
|
+
scope,
|
|
30
|
+
currency,
|
|
31
|
+
locale,
|
|
32
|
+
t,
|
|
33
|
+
onInputChange,
|
|
34
|
+
onApply,
|
|
35
|
+
onRemove,
|
|
36
|
+
onScopeChange,
|
|
37
|
+
}: AdminChangePromoEditorProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="mb-3 rounded-lg border border-sky-200 bg-white/80 p-3">
|
|
40
|
+
<PromoCodeInput
|
|
41
|
+
promoCodeInput={promoCodeInput}
|
|
42
|
+
appliedPromoCode={appliedPromoCode}
|
|
43
|
+
promoCodeError={promoCodeError}
|
|
44
|
+
promoCodeValidating={promoCodeValidating}
|
|
45
|
+
promoDiscountAmount={0}
|
|
46
|
+
currency={currency}
|
|
47
|
+
locale={locale}
|
|
48
|
+
t={t}
|
|
49
|
+
onInputChange={onInputChange}
|
|
50
|
+
onApply={onApply}
|
|
51
|
+
onRemove={onRemove}
|
|
52
|
+
hideDiscountAmount
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{previousPromoCode ? (
|
|
56
|
+
<p className="mt-2 text-xs text-stone-600">
|
|
57
|
+
Earlier value keeps its existing <span className="font-semibold">{previousPromoCode}</span> allocation.
|
|
58
|
+
It is not automatically reused on this amendment.
|
|
59
|
+
</p>
|
|
60
|
+
) : null}
|
|
61
|
+
|
|
62
|
+
{appliedPromoCode ? (
|
|
63
|
+
<fieldset className="mt-3 space-y-2">
|
|
64
|
+
<legend className="text-xs font-semibold text-stone-800">Apply this code to</legend>
|
|
65
|
+
<label className="flex cursor-pointer items-start gap-2 text-xs text-stone-700">
|
|
66
|
+
<input
|
|
67
|
+
type="radio"
|
|
68
|
+
name="adminPromoApplicationScope"
|
|
69
|
+
value="CHANGE_ONLY"
|
|
70
|
+
checked={scope === 'CHANGE_ONLY'}
|
|
71
|
+
onChange={() => onScopeChange('CHANGE_ONLY')}
|
|
72
|
+
/>
|
|
73
|
+
<span>
|
|
74
|
+
<span className="font-semibold">This change only</span>
|
|
75
|
+
<span className="block text-stone-500">Discount only eligible positive value introduced now.</span>
|
|
76
|
+
</span>
|
|
77
|
+
</label>
|
|
78
|
+
<label className="flex cursor-pointer items-start gap-2 text-xs text-stone-700">
|
|
79
|
+
<input
|
|
80
|
+
type="radio"
|
|
81
|
+
name="adminPromoApplicationScope"
|
|
82
|
+
value="ENTIRE_BOOKING"
|
|
83
|
+
checked={scope === 'ENTIRE_BOOKING'}
|
|
84
|
+
onChange={() => onScopeChange('ENTIRE_BOOKING')}
|
|
85
|
+
/>
|
|
86
|
+
<span>
|
|
87
|
+
<span className="font-semibold">Entire active booking</span>
|
|
88
|
+
<span className="block text-stone-500">
|
|
89
|
+
Top up currently eligible value without duplicating discounts already allocated by earlier ledger changes.
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
</label>
|
|
93
|
+
</fieldset>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -9,16 +9,14 @@ import type { Locale } from '../../lib/booking/i18n/config';
|
|
|
9
9
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
10
10
|
import type { Currency } from './CurrencySwitcher';
|
|
11
11
|
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
12
|
+
import { collapseAdminPromoLines } from './admin-change-receipt-lines';
|
|
12
13
|
import {
|
|
13
|
-
adminRemovalOperationLabel,
|
|
14
14
|
noRefundRemovedValue,
|
|
15
|
-
normalizeReturnPriceTreatment,
|
|
16
15
|
refundDecisionsComplete,
|
|
17
|
-
releaseRefundDispositionOptions,
|
|
18
|
-
returnPriceTreatmentOptions,
|
|
19
16
|
type AdminReleaseRefundDisposition,
|
|
20
|
-
type AdminReturnPriceTreatment,
|
|
21
17
|
} from './admin-refund-disposition';
|
|
18
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
19
|
+
import { isProductFamilyReceiptConversion } from './change-booking-flow-helpers';
|
|
22
20
|
|
|
23
21
|
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
24
22
|
type OriginalReceipt = NonNullable<ChangeBookingFlowProps['originalReceipt']>;
|
|
@@ -27,10 +25,17 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
27
25
|
originalReceipt: OriginalReceipt;
|
|
28
26
|
/** Amendment-only rows returned by the Pricing V2 server quote. */
|
|
29
27
|
amendmentLines: PriceSummaryLine[] | null;
|
|
28
|
+
/** Full receipt after the amendment, used for clear family-conversion comparisons. */
|
|
29
|
+
resultingReceiptLines?: PriceSummaryLine[] | null;
|
|
30
30
|
/** Signed server balance delta. Never calculated by this component. */
|
|
31
31
|
amountDue: number | null;
|
|
32
32
|
/** Full resulting booking total returned by the same server quote. */
|
|
33
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;
|
|
34
39
|
selectionChanged: boolean;
|
|
35
40
|
quoteError?: string | null;
|
|
36
41
|
amountDueLabel: string;
|
|
@@ -39,6 +44,8 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
39
44
|
t: TranslationFn;
|
|
40
45
|
taxRate?: number;
|
|
41
46
|
adjustments?: ReactNode;
|
|
47
|
+
promoEditor?: ReactNode;
|
|
48
|
+
promoCode?: string | null;
|
|
42
49
|
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
43
50
|
returnPriceTreatmentOperations?: AdminAmendmentOperationSnapshot[];
|
|
44
51
|
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
@@ -49,6 +56,8 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
49
56
|
disposition: AdminReleaseRefundDisposition | null,
|
|
50
57
|
) => void;
|
|
51
58
|
refundQuoteLoading?: boolean;
|
|
59
|
+
sourceProductName?: string | null;
|
|
60
|
+
targetProductName?: string | null;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
function normalizeLocale(locale: string): Locale {
|
|
@@ -79,8 +88,13 @@ function amendmentLineOrder(line: PriceSummaryLine): number {
|
|
|
79
88
|
export function AdminChangeReceiptComparison({
|
|
80
89
|
originalReceipt,
|
|
81
90
|
amendmentLines,
|
|
91
|
+
resultingReceiptLines = null,
|
|
82
92
|
amountDue,
|
|
83
93
|
updatedTotal,
|
|
94
|
+
netPaid,
|
|
95
|
+
currentPaymentCredit = 0,
|
|
96
|
+
projectedPaymentCredit = 0,
|
|
97
|
+
refundCandidate = 0,
|
|
84
98
|
selectionChanged,
|
|
85
99
|
quoteError,
|
|
86
100
|
amountDueLabel,
|
|
@@ -89,31 +103,50 @@ export function AdminChangeReceiptComparison({
|
|
|
89
103
|
t,
|
|
90
104
|
taxRate,
|
|
91
105
|
adjustments,
|
|
106
|
+
promoEditor,
|
|
107
|
+
promoCode,
|
|
92
108
|
refundDecisionOperations = [],
|
|
93
|
-
returnPriceTreatmentOperations = [],
|
|
94
|
-
allowedRefundDispositionsByOperationId = {},
|
|
95
109
|
removedValueByOperationId = {},
|
|
96
110
|
refundDispositionsByOperationId = {},
|
|
97
|
-
onRefundDispositionChange,
|
|
98
111
|
refundQuoteLoading = false,
|
|
99
112
|
}: AdminChangeReceiptComparisonProps) {
|
|
113
|
+
const readableQuoteError = friendlyChangeBookingError(quoteError);
|
|
100
114
|
const displayLocale = normalizeLocale(locale);
|
|
101
115
|
const existingLines = originalLines(originalReceipt);
|
|
102
116
|
const originalCurrency = originalReceipt.currency ?? currency;
|
|
103
117
|
const quoteReady = amendmentLines != null && amountDue != null && updatedTotal != null;
|
|
118
|
+
const settlementReady = quoteReady && netPaid != null;
|
|
119
|
+
const currentBalanceOwing = settlementReady
|
|
120
|
+
? Math.max(
|
|
121
|
+
0,
|
|
122
|
+
Math.round(
|
|
123
|
+
(originalReceipt.total - netPaid - currentPaymentCredit) * 100,
|
|
124
|
+
) / 100,
|
|
125
|
+
)
|
|
126
|
+
: null;
|
|
127
|
+
const projectedBalanceOwing = settlementReady
|
|
128
|
+
? Math.max(
|
|
129
|
+
0,
|
|
130
|
+
Math.round(
|
|
131
|
+
(updatedTotal - netPaid - projectedPaymentCredit) * 100,
|
|
132
|
+
) / 100,
|
|
133
|
+
)
|
|
134
|
+
: null;
|
|
135
|
+
const balanceReduction = settlementReady
|
|
136
|
+
? Math.max(
|
|
137
|
+
0,
|
|
138
|
+
Math.round(((currentBalanceOwing ?? 0) - (projectedBalanceOwing ?? 0)) * 100) / 100,
|
|
139
|
+
)
|
|
140
|
+
: 0;
|
|
104
141
|
const hasRefundDecisions = refundDecisionOperations.length > 0;
|
|
105
|
-
const hasReturnPriceTreatments = returnPriceTreatmentOperations.length > 0;
|
|
106
142
|
const refundSelectionsComplete = refundDecisionsComplete(
|
|
107
143
|
refundDecisionOperations,
|
|
108
144
|
refundDispositionsByOperationId,
|
|
109
145
|
);
|
|
110
146
|
const settlementPending = hasRefundDecisions && (!refundSelectionsComplete || refundQuoteLoading);
|
|
111
|
-
const returnRepricePending = hasReturnPriceTreatments &&
|
|
112
|
-
returnPriceTreatmentOperations.some(
|
|
113
|
-
(operation) => refundDispositionsByOperationId[operation.operationId] != null,
|
|
114
|
-
) &&
|
|
115
|
-
refundQuoteLoading;
|
|
116
147
|
const displayedAmountDue = settlementPending ? null : amountDue;
|
|
148
|
+
const bookingTotalChange =
|
|
149
|
+
updatedTotal == null ? null : Math.round((updatedTotal - originalReceipt.total) * 100) / 100;
|
|
117
150
|
const removedWithoutRefund = noRefundRemovedValue(
|
|
118
151
|
refundDecisionOperations,
|
|
119
152
|
refundDispositionsByOperationId,
|
|
@@ -135,23 +168,53 @@ export function AdminChangeReceiptComparison({
|
|
|
135
168
|
.sort((a, b) => amendmentLineOrder(a.line) - amendmentLineOrder(b.line) || a.index - b.index)
|
|
136
169
|
.map(({ line }) => line)
|
|
137
170
|
: [];
|
|
138
|
-
const
|
|
171
|
+
const collapsedAmendmentNonTaxLines = collapseAdminPromoLines(amendmentNonTaxLines, promoCode);
|
|
172
|
+
const amendmentSubtotal = collapsedAmendmentNonTaxLines.reduce(
|
|
139
173
|
(sum, line) => sum + (line.kind === 'ticket' ? line.itemTotal : line.amount),
|
|
140
174
|
0,
|
|
141
175
|
);
|
|
142
176
|
const amendmentDisplayLines: PriceSummaryLine[] = amendmentLines
|
|
143
177
|
? [
|
|
144
|
-
...
|
|
178
|
+
...collapsedAmendmentNonTaxLines,
|
|
145
179
|
...(Math.abs(amendmentTax) >= 0.005
|
|
146
180
|
? [{
|
|
147
181
|
kind: 'line' as const,
|
|
148
|
-
label: amendmentTax < 0 ? '
|
|
182
|
+
label: amendmentTax < 0 ? 'Tax reduction' : 'Additional tax',
|
|
149
183
|
amount: amendmentTax,
|
|
150
184
|
type: 'TAX',
|
|
151
185
|
}]
|
|
152
186
|
: []),
|
|
153
187
|
]
|
|
154
188
|
: [];
|
|
189
|
+
const familyConversionReceiptLines = collapseAdminPromoLines(
|
|
190
|
+
resultingReceiptLines ?? [],
|
|
191
|
+
promoCode,
|
|
192
|
+
);
|
|
193
|
+
const isProductFamilyConversion = isProductFamilyReceiptConversion(
|
|
194
|
+
existingLines,
|
|
195
|
+
familyConversionReceiptLines,
|
|
196
|
+
);
|
|
197
|
+
const summaryDisplayLines = isProductFamilyConversion
|
|
198
|
+
? familyConversionReceiptLines
|
|
199
|
+
: amendmentDisplayLines;
|
|
200
|
+
const summarySubtotal = isProductFamilyConversion
|
|
201
|
+
? familyConversionReceiptLines.reduce((sum, line) => {
|
|
202
|
+
if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
|
|
203
|
+
return sum + (line.kind === 'ticket' ? line.itemTotal : line.amount);
|
|
204
|
+
}, 0)
|
|
205
|
+
: amendmentSubtotal;
|
|
206
|
+
const summaryTotal = isProductFamilyConversion
|
|
207
|
+
? updatedTotal
|
|
208
|
+
: bookingTotalChange ?? amountDue;
|
|
209
|
+
const translatedChangeTotalLabel = t('booking.changeToBookingTotal');
|
|
210
|
+
const summaryTotalLabel = isProductFamilyConversion
|
|
211
|
+
? 'New booking total'
|
|
212
|
+
: translatedChangeTotalLabel && translatedChangeTotalLabel !== 'booking.changeToBookingTotal'
|
|
213
|
+
? translatedChangeTotalLabel
|
|
214
|
+
: 'Change to booking total';
|
|
215
|
+
const displayedSettlementAmount = refundCandidate > 0.005
|
|
216
|
+
? refundCandidate
|
|
217
|
+
: displayedAmountDue;
|
|
155
218
|
const amountDueClass =
|
|
156
219
|
displayedAmountDue != null && displayedAmountDue < -0.005
|
|
157
220
|
? 'text-emerald-700'
|
|
@@ -161,107 +224,6 @@ export function AdminChangeReceiptComparison({
|
|
|
161
224
|
|
|
162
225
|
return (
|
|
163
226
|
<div className="min-w-0 space-y-3 overflow-visible">
|
|
164
|
-
{hasReturnPriceTreatments ? (
|
|
165
|
-
<section className="rounded-lg border border-sky-300 bg-sky-50 p-3" aria-label="Return price treatment">
|
|
166
|
-
<div className="mb-3">
|
|
167
|
-
<h3 className="text-sm font-semibold text-stone-900">Return price</h3>
|
|
168
|
-
<p className="mt-1 text-xs text-stone-600">
|
|
169
|
-
By default the original paid return price stays on the booking. You can optionally reduce it and choose refund treatment.
|
|
170
|
-
</p>
|
|
171
|
-
</div>
|
|
172
|
-
<div className="space-y-3">
|
|
173
|
-
{returnPriceTreatmentOperations.map((operation) => {
|
|
174
|
-
const options = returnPriceTreatmentOptions(
|
|
175
|
-
allowedRefundDispositionsByOperationId[operation.operationId],
|
|
176
|
-
);
|
|
177
|
-
const selectedValue: AdminReturnPriceTreatment = normalizeReturnPriceTreatment(
|
|
178
|
-
refundDispositionsByOperationId[operation.operationId],
|
|
179
|
-
);
|
|
180
|
-
const selected = options.find((option) => option.value === selectedValue);
|
|
181
|
-
return (
|
|
182
|
-
<div key={operation.operationId} className="rounded-md border border-sky-200 bg-white p-3">
|
|
183
|
-
<div className="mb-2 text-sm font-medium capitalize text-stone-900">
|
|
184
|
-
{adminRemovalOperationLabel(operation)}
|
|
185
|
-
</div>
|
|
186
|
-
<select
|
|
187
|
-
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
|
|
188
|
-
value={selectedValue}
|
|
189
|
-
onChange={(event) => {
|
|
190
|
-
const value = event.target.value as AdminReturnPriceTreatment;
|
|
191
|
-
onRefundDispositionChange?.(
|
|
192
|
-
operation.operationId,
|
|
193
|
-
value === 'KEEP_FLOOR' ? null : value,
|
|
194
|
-
);
|
|
195
|
-
}}
|
|
196
|
-
aria-label={`Return price treatment for ${adminRemovalOperationLabel(operation)}`}
|
|
197
|
-
>
|
|
198
|
-
{options.map((option) => (
|
|
199
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
200
|
-
))}
|
|
201
|
-
</select>
|
|
202
|
-
{selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
})}
|
|
206
|
-
</div>
|
|
207
|
-
{returnRepricePending ? (
|
|
208
|
-
<p className="mt-3 text-xs font-medium text-sky-800">Recalculating settlement…</p>
|
|
209
|
-
) : null}
|
|
210
|
-
</section>
|
|
211
|
-
) : null}
|
|
212
|
-
|
|
213
|
-
{hasRefundDecisions ? (
|
|
214
|
-
<section className="rounded-lg border border-amber-300 bg-amber-50 p-3" aria-label="Refund treatment">
|
|
215
|
-
<div className="mb-3">
|
|
216
|
-
<h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
|
|
217
|
-
<p className="mt-1 text-xs text-stone-600">
|
|
218
|
-
Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
|
|
219
|
-
</p>
|
|
220
|
-
</div>
|
|
221
|
-
<div className="space-y-3">
|
|
222
|
-
{refundDecisionOperations.map((operation) => {
|
|
223
|
-
const options = releaseRefundDispositionOptions(
|
|
224
|
-
allowedRefundDispositionsByOperationId[operation.operationId],
|
|
225
|
-
);
|
|
226
|
-
const selectedValue = refundDispositionsByOperationId[operation.operationId];
|
|
227
|
-
const selected = options.find((option) => option.value === selectedValue);
|
|
228
|
-
const removedValue = removedValueByOperationId[operation.operationId];
|
|
229
|
-
return (
|
|
230
|
-
<div key={operation.operationId} className="rounded-md border border-amber-200 bg-white p-3">
|
|
231
|
-
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 text-sm font-medium text-stone-900">
|
|
232
|
-
<span className="capitalize">{adminRemovalOperationLabel(operation)}</span>
|
|
233
|
-
{Number.isFinite(removedValue) ? (
|
|
234
|
-
<span>{formatCurrencyAmount(removedValue, currency, displayLocale)}</span>
|
|
235
|
-
) : null}
|
|
236
|
-
</div>
|
|
237
|
-
<select
|
|
238
|
-
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900"
|
|
239
|
-
value={selectedValue ?? ''}
|
|
240
|
-
onChange={(event) => {
|
|
241
|
-
const value = event.target.value;
|
|
242
|
-
onRefundDispositionChange?.(
|
|
243
|
-
operation.operationId,
|
|
244
|
-
value ? value as AdminReleaseRefundDisposition : null,
|
|
245
|
-
);
|
|
246
|
-
}}
|
|
247
|
-
aria-label={`Refund treatment for ${adminRemovalOperationLabel(operation)}`}
|
|
248
|
-
>
|
|
249
|
-
<option value="">Choose refund treatment…</option>
|
|
250
|
-
{options.map((option) => (
|
|
251
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
252
|
-
))}
|
|
253
|
-
</select>
|
|
254
|
-
{selected ? <p className="mt-2 text-xs text-stone-600">{selected.description}</p> : null}
|
|
255
|
-
</div>
|
|
256
|
-
);
|
|
257
|
-
})}
|
|
258
|
-
</div>
|
|
259
|
-
{refundQuoteLoading && refundSelectionsComplete ? (
|
|
260
|
-
<p className="mt-3 text-xs font-medium text-amber-800">Recalculating settlement…</p>
|
|
261
|
-
) : null}
|
|
262
|
-
</section>
|
|
263
|
-
) : null}
|
|
264
|
-
|
|
265
227
|
<div className="grid gap-4 md:grid-cols-2">
|
|
266
228
|
<section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
|
|
267
229
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
@@ -286,49 +248,61 @@ export function AdminChangeReceiptComparison({
|
|
|
286
248
|
<section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
|
|
287
249
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
288
250
|
<div>
|
|
289
|
-
<h3 className="text-sm font-semibold text-stone-900">
|
|
290
|
-
|
|
251
|
+
<h3 className="text-sm font-semibold text-stone-900">
|
|
252
|
+
{isProductFamilyConversion ? 'New purchase' : 'Current change'}
|
|
253
|
+
</h3>
|
|
254
|
+
{isProductFamilyConversion ? (
|
|
255
|
+
<p className="mt-0.5 text-xs text-stone-500">
|
|
256
|
+
Server-priced booking after conversion
|
|
257
|
+
</p>
|
|
258
|
+
) : null}
|
|
291
259
|
</div>
|
|
292
260
|
<span className="shrink-0 text-xs font-medium text-stone-500">
|
|
293
|
-
{
|
|
261
|
+
{isProductFamilyConversion
|
|
262
|
+
? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
|
|
263
|
+
: bookingTotalChange == null
|
|
264
|
+
? '—'
|
|
265
|
+
: formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
|
|
294
266
|
</span>
|
|
295
267
|
</div>
|
|
268
|
+
{promoEditor}
|
|
296
269
|
{!selectionChanged ? (
|
|
297
270
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
298
271
|
<p className="text-sm font-medium text-stone-700">No changes selected</p>
|
|
299
272
|
<p className="mt-1 text-xs text-stone-500">Change tickets or another booking option to request a price.</p>
|
|
300
273
|
</div>
|
|
301
|
-
) :
|
|
274
|
+
) : readableQuoteError ? (
|
|
302
275
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-4 text-center" role="alert">
|
|
303
|
-
<p className="text-sm font-medium text-red-800">{
|
|
276
|
+
<p className="text-sm font-medium text-red-800">{readableQuoteError}</p>
|
|
304
277
|
</div>
|
|
305
278
|
) : !quoteReady ? (
|
|
306
279
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
307
|
-
<p className="text-sm font-medium text-stone-700">
|
|
280
|
+
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
308
281
|
</div>
|
|
309
282
|
) : settlementPending ? (
|
|
310
|
-
<div className="rounded-md border border-dashed border-
|
|
311
|
-
<p className="text-sm font-medium text-
|
|
312
|
-
<p className="mt-1 text-xs text-stone-600">Choose how to handle the removed paid value above.</p>
|
|
283
|
+
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
284
|
+
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
313
285
|
</div>
|
|
314
|
-
) :
|
|
286
|
+
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
315
287
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
316
288
|
<p className="text-sm font-medium text-stone-800">No refund will be created</p>
|
|
317
289
|
<p className="mt-1 text-xs text-stone-600">
|
|
318
290
|
{formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
|
|
319
291
|
</p>
|
|
320
292
|
</div>
|
|
321
|
-
) :
|
|
293
|
+
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
322
294
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
323
295
|
<p className="text-sm font-medium text-stone-700">No pricing changes</p>
|
|
324
296
|
</div>
|
|
325
297
|
) : (
|
|
326
298
|
<PriceSummary
|
|
327
|
-
lines={
|
|
328
|
-
total={amountDue}
|
|
299
|
+
lines={summaryDisplayLines}
|
|
300
|
+
total={summaryTotal ?? amountDue}
|
|
301
|
+
totalLabel={summaryTotalLabel}
|
|
302
|
+
showPositiveTotalSign={!isProductFamilyConversion}
|
|
329
303
|
currency={currency}
|
|
330
304
|
locale={displayLocale}
|
|
331
|
-
subtotal={
|
|
305
|
+
subtotal={summarySubtotal}
|
|
332
306
|
size="sm"
|
|
333
307
|
t={t}
|
|
334
308
|
/>
|
|
@@ -336,31 +310,82 @@ export function AdminChangeReceiptComparison({
|
|
|
336
310
|
</section>
|
|
337
311
|
</div>
|
|
338
312
|
|
|
339
|
-
{quoteReady ? (
|
|
340
|
-
<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">
|
|
341
|
-
<div>
|
|
342
|
-
<h3 className="text-sm font-semibold text-stone-900">Updated booking total</h3>
|
|
343
|
-
<p className="mt-0.5 text-xs text-stone-500">Authoritative total returned by the server quote</p>
|
|
344
|
-
</div>
|
|
345
|
-
<span className="text-lg font-semibold tabular-nums text-stone-900">
|
|
346
|
-
{formatCurrencyAmount(updatedTotal, currency, displayLocale)}
|
|
347
|
-
</span>
|
|
348
|
-
</section>
|
|
349
|
-
) : null}
|
|
350
|
-
|
|
351
313
|
{adjustments ? (
|
|
352
314
|
<section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
|
|
353
|
-
<h3 className="mb-2 text-sm font-semibold text-stone-900">
|
|
315
|
+
<h3 className="mb-2 text-sm font-semibold text-stone-900">Add custom line items</h3>
|
|
354
316
|
{adjustments}
|
|
355
317
|
</section>
|
|
356
318
|
) : null}
|
|
357
319
|
|
|
320
|
+
{quoteReady ? (
|
|
321
|
+
<section className="rounded-lg border border-stone-300 bg-white px-3 py-3 shadow-sm">
|
|
322
|
+
<h3 className="text-sm font-semibold text-stone-900">After this change</h3>
|
|
323
|
+
{settlementReady ? (
|
|
324
|
+
<div className="mt-3 space-y-2 border-t border-stone-200 pt-3 text-xs">
|
|
325
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
326
|
+
<span>Updated booking total</span>
|
|
327
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
328
|
+
{formatCurrencyAmount(updatedTotal, currency, displayLocale)}
|
|
329
|
+
</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
332
|
+
<span>Customer already paid</span>
|
|
333
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
334
|
+
{formatCurrencyAmount(netPaid, currency, displayLocale)}
|
|
335
|
+
</span>
|
|
336
|
+
</div>
|
|
337
|
+
{projectedPaymentCredit > 0.005 ? (
|
|
338
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
339
|
+
<span>Payment credit applied</span>
|
|
340
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
341
|
+
{formatCurrencyAmount(projectedPaymentCredit, currency, displayLocale)}
|
|
342
|
+
</span>
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
<div className="flex items-center justify-between gap-3 text-stone-600">
|
|
346
|
+
<span>Balance owing before change</span>
|
|
347
|
+
<span className="font-medium tabular-nums text-stone-900">
|
|
348
|
+
{formatCurrencyAmount(currentBalanceOwing ?? 0, currency, displayLocale)}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
{balanceReduction > 0.005 ? (
|
|
352
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
|
|
353
|
+
<span>
|
|
354
|
+
{isProductFamilyConversion
|
|
355
|
+
? 'Previous unpaid balance removed'
|
|
356
|
+
: 'Balance reduction'}
|
|
357
|
+
</span>
|
|
358
|
+
<span className="tabular-nums">
|
|
359
|
+
{formatCurrencyAmount(balanceReduction, currency, displayLocale)}
|
|
360
|
+
</span>
|
|
361
|
+
</div>
|
|
362
|
+
) : null}
|
|
363
|
+
{refundCandidate > 0.005 ? (
|
|
364
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-emerald-700">
|
|
365
|
+
<span>Amount to refund customer</span>
|
|
366
|
+
<span className="tabular-nums">
|
|
367
|
+
{formatCurrencyAmount(refundCandidate, currency, displayLocale)}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
) : amountDue < -0.005 ? (
|
|
371
|
+
<div className="flex items-center justify-between gap-3 font-semibold text-stone-600">
|
|
372
|
+
<span>Amount to refund customer</span>
|
|
373
|
+
<span className="tabular-nums">
|
|
374
|
+
{formatCurrencyAmount(0, currency, displayLocale)}
|
|
375
|
+
</span>
|
|
376
|
+
</div>
|
|
377
|
+
) : null}
|
|
378
|
+
</div>
|
|
379
|
+
) : null}
|
|
380
|
+
</section>
|
|
381
|
+
) : null}
|
|
382
|
+
|
|
358
383
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
|
359
384
|
<span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
|
|
360
385
|
<span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
|
|
361
|
-
{!selectionChanged ||
|
|
386
|
+
{!selectionChanged || displayedSettlementAmount == null
|
|
362
387
|
? '—'
|
|
363
|
-
: formatCurrencyAmount(
|
|
388
|
+
: formatCurrencyAmount(displayedSettlementAmount, currency, displayLocale)}
|
|
364
389
|
</span>
|
|
365
390
|
</div>
|
|
366
391
|
</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;
|
|
@@ -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}
|