@ticketboothapp/booking 1.2.76 → 1.2.78
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/CHANGE_BOOKING_BE_HANDOFF.md +36 -0
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +163 -37
- package/src/components/booking/CheckoutForm.tsx +7 -0
- package/src/components/booking/PriceBreakdown.tsx +32 -8
- package/src/components/booking/PriceSummary.tsx +50 -9
- package/src/components/booking/booking-flow-ui.ts +5 -0
- package/src/components/booking/booking-flow.css +8 -0
- package/src/components/booking/useEditableSummaryLines.ts +244 -0
- package/src/lib/booking/change-booking-server-preview.ts +1 -0
- package/src/lib/booking-api.ts +49 -0
|
@@ -100,3 +100,39 @@ Pickers show **catalog** prices by default (same booking **currency** as the ori
|
|
|
100
100
|
- **Provider change API:** When `pricingAdjustment.additionalAdjustments` includes the same rows, persist them on the new receipt and charge the **same** total as `newTotalAmount` (within your rounding rules).
|
|
101
101
|
|
|
102
102
|
If the server ignores `manualLineAdjustments` or `additionalAdjustments`, the customer will see the FE total but pay a different amount.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Admin FE-authoritative quote path (new)
|
|
107
|
+
|
|
108
|
+
For provider dashboard / admin tooling, we need a dedicated quote route where the **frontend is authoritative** for
|
|
109
|
+
the full receipt preview (totals + line items), and BE does not re-price those values.
|
|
110
|
+
|
|
111
|
+
### Endpoint
|
|
112
|
+
|
|
113
|
+
- **`POST /1/admin/bookings/:bookingReference/change/quote?lastName=...`**
|
|
114
|
+
|
|
115
|
+
Keep the public self-serve endpoint unchanged.
|
|
116
|
+
|
|
117
|
+
### Request contract (admin)
|
|
118
|
+
|
|
119
|
+
Same selection fields as `ChangeBookingQuoteRequest`, plus:
|
|
120
|
+
|
|
121
|
+
- `feReceipt`:
|
|
122
|
+
- `subtotal` (major units)
|
|
123
|
+
- `tax` (major units)
|
|
124
|
+
- `total` (major units)
|
|
125
|
+
- `currency?`
|
|
126
|
+
- `lineItems: Array<{ label?: string; amount?: number; type?: string; quantity?: number }>`
|
|
127
|
+
- `feAmountDueMajorUnits?` (optional signed delta the FE displays; `newTotal - previousTotal`)
|
|
128
|
+
|
|
129
|
+
### Backend expectations
|
|
130
|
+
|
|
131
|
+
- Validate admin auth + booking/availability eligibility only.
|
|
132
|
+
- Persist `feReceipt` on the change intent as the quote source-of-truth.
|
|
133
|
+
- Return a `ChangeBookingQuoteResponse`-compatible payload using the stored FE receipt:
|
|
134
|
+
- `newReceipt` / `proposed` line items from `feReceipt.lineItems`
|
|
135
|
+
- `newReceipt.subtotal/tax/total` from `feReceipt`
|
|
136
|
+
- `amountDueCents` / `balanceDeltaMajorUnits` / `priceDiff` coherent with stored totals
|
|
137
|
+
- `changeIntentId`, `canProceed`, `reasonIfBlocked`
|
|
138
|
+
- Payment intent + apply/confirm for this change intent must use persisted FE totals/lines (no repricing).
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
cancelReservationBestEffort,
|
|
10
10
|
createPaymentIntent,
|
|
11
11
|
quoteChangeBooking,
|
|
12
|
+
quoteChangeBookingAdminFeReceipt,
|
|
12
13
|
confirmFreeChangeBooking,
|
|
13
14
|
createChangeBookingPaymentIntent,
|
|
14
15
|
confirmBookingWithoutPayment,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
isInsufficientCapacityReserveError,
|
|
26
27
|
describeStandardTourCapacityConflictMessage,
|
|
27
28
|
reportReserveCapacityConflictClientContext,
|
|
29
|
+
type AdminFeAuthoritativeReceipt,
|
|
28
30
|
} from '../../lib/booking-api';
|
|
29
31
|
import {
|
|
30
32
|
EARLIEST_AVAILABILITY_DATE,
|
|
@@ -102,6 +104,7 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
|
102
104
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
103
105
|
import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
|
|
104
106
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
107
|
+
import { useEditableSummaryLines } from './useEditableSummaryLines';
|
|
105
108
|
|
|
106
109
|
/**
|
|
107
110
|
* ## Pricing contract (customer self-serve)
|
|
@@ -148,6 +151,26 @@ function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): Pri
|
|
|
148
151
|
});
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
function mapSummaryLinesToFeReceiptLineItems(lines: PriceSummaryLine[]): NonNullable<AdminFeAuthoritativeReceipt['lineItems']> {
|
|
155
|
+
return lines.map((line) => {
|
|
156
|
+
if (line.kind === 'ticket') {
|
|
157
|
+
return {
|
|
158
|
+
label: line.category,
|
|
159
|
+
amount: roundMoney(line.itemTotal),
|
|
160
|
+
type: 'TICKET',
|
|
161
|
+
quantity: line.qty,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const normalizedType = String(line.type ?? '').trim().toUpperCase();
|
|
165
|
+
return {
|
|
166
|
+
label: line.label,
|
|
167
|
+
amount: roundMoney(line.amount),
|
|
168
|
+
type: normalizedType || undefined,
|
|
169
|
+
quantity: line.quantity ?? undefined,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
|
|
152
175
|
const labels: Record<string, string> = {
|
|
153
176
|
ADULT: 'adult',
|
|
@@ -831,6 +854,10 @@ export function AdminChangeBookingFlow({
|
|
|
831
854
|
}
|
|
832
855
|
}, [initialValues?.dateTime, companyTimezone]);
|
|
833
856
|
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
857
|
+
const useAdminFeAuthoritativeQuote =
|
|
858
|
+
isAdmin &&
|
|
859
|
+
isProviderDashboardChange &&
|
|
860
|
+
Boolean(flowUi?.adminFeAuthoritativeQuoteEnabled);
|
|
834
861
|
/** Any change from an existing booking (public or provider). */
|
|
835
862
|
const isChangeBookingContext = Boolean(initialValues?.bookingReference?.trim());
|
|
836
863
|
/**
|
|
@@ -3120,6 +3147,24 @@ export function AdminChangeBookingFlow({
|
|
|
3120
3147
|
),
|
|
3121
3148
|
[checkoutPriceSummaryLinesForCheckout],
|
|
3122
3149
|
);
|
|
3150
|
+
const {
|
|
3151
|
+
editableCheckoutPriceSummaryLines,
|
|
3152
|
+
editableSummaryLineAmountInputs,
|
|
3153
|
+
editableSummaryLineLabelInputs,
|
|
3154
|
+
editableSummaryPreSubtotalDelta,
|
|
3155
|
+
editableSummaryPreSubtotalDebugRows,
|
|
3156
|
+
onLineAmountInputChange: handleEditableLineAmountInputChange,
|
|
3157
|
+
onLineAmountInputBlur: handleEditableLineAmountInputBlur,
|
|
3158
|
+
onLineAmountReset: handleEditableLineAmountReset,
|
|
3159
|
+
} = useEditableSummaryLines(
|
|
3160
|
+
checkoutPriceSummaryLinesForCheckout,
|
|
3161
|
+
{
|
|
3162
|
+
enabled: showProviderPricingInlineEditor,
|
|
3163
|
+
onChange: providerPricingUi?.onLineAmountInputChange,
|
|
3164
|
+
onBlur: providerPricingUi?.onLineAmountInputBlur,
|
|
3165
|
+
onReset: providerPricingUi?.onLineAmountReset,
|
|
3166
|
+
},
|
|
3167
|
+
);
|
|
3123
3168
|
|
|
3124
3169
|
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
3125
3170
|
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
@@ -3709,7 +3754,11 @@ export function AdminChangeBookingFlow({
|
|
|
3709
3754
|
* UI-only gate — does not alter ticket line items or receipt-floor rules.
|
|
3710
3755
|
*/
|
|
3711
3756
|
const showChangeFlowManualPriceLines =
|
|
3712
|
-
changeFlowFinalPricePending || selfServePricingConfirmed;
|
|
3757
|
+
hasEffectiveChangeSelection && (changeFlowFinalPricePending || selfServePricingConfirmed);
|
|
3758
|
+
const showAdminCustomLineEditor =
|
|
3759
|
+
isAdmin &&
|
|
3760
|
+
selectedAvailability != null &&
|
|
3761
|
+
totalQuantity > 0;
|
|
3713
3762
|
|
|
3714
3763
|
const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
|
|
3715
3764
|
providerPreview: providerTotalsPreview,
|
|
@@ -3741,8 +3790,46 @@ export function AdminChangeBookingFlow({
|
|
|
3741
3790
|
? displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
|
|
3742
3791
|
: displayedChangeAmountsRaw.total
|
|
3743
3792
|
);
|
|
3793
|
+
const displayChangeFlowProposedTotalWithEditableLines = roundMoney(
|
|
3794
|
+
displayChangeFlowProposedTotal + editableSummaryPreSubtotalDelta
|
|
3795
|
+
);
|
|
3796
|
+
const adminFeAuthoritativeReceipt = useMemo((): AdminFeAuthoritativeReceipt => {
|
|
3797
|
+
const hasTaxLine = editableCheckoutPriceSummaryLines.some(
|
|
3798
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
|
|
3799
|
+
);
|
|
3800
|
+
const lineItems = mapSummaryLinesToFeReceiptLineItems(editableCheckoutPriceSummaryLines);
|
|
3801
|
+
if (!hasTaxLine && Math.abs(displayChangeFlowTax) >= 0.0005) {
|
|
3802
|
+
lineItems.push({
|
|
3803
|
+
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
3804
|
+
amount: roundMoney(displayChangeFlowTax),
|
|
3805
|
+
type: 'TAX',
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
let subtotal = roundMoney(displayChangeFlowSubtotal + editableSummaryPreSubtotalDelta);
|
|
3809
|
+
const tax = roundMoney(displayChangeFlowTax);
|
|
3810
|
+
const expectedTotal = roundMoney(displayChangeFlowProposedTotalWithEditableLines);
|
|
3811
|
+
const recomputed = roundMoney(subtotal + tax);
|
|
3812
|
+
if (Math.abs(recomputed - expectedTotal) >= 0.005) {
|
|
3813
|
+
subtotal = roundMoney(expectedTotal - tax);
|
|
3814
|
+
}
|
|
3815
|
+
return {
|
|
3816
|
+
subtotal,
|
|
3817
|
+
tax,
|
|
3818
|
+
total: expectedTotal,
|
|
3819
|
+
currency,
|
|
3820
|
+
lineItems,
|
|
3821
|
+
};
|
|
3822
|
+
}, [
|
|
3823
|
+
editableCheckoutPriceSummaryLines,
|
|
3824
|
+
displayChangeFlowTax,
|
|
3825
|
+
displayChangeFlowSubtotal,
|
|
3826
|
+
editableSummaryPreSubtotalDelta,
|
|
3827
|
+
displayChangeFlowProposedTotalWithEditableLines,
|
|
3828
|
+
currency,
|
|
3829
|
+
t,
|
|
3830
|
+
]);
|
|
3744
3831
|
|
|
3745
|
-
const
|
|
3832
|
+
const changeFlowClientEstimateDueBase = (() => {
|
|
3746
3833
|
if (!originalReceipt) return totalPrice;
|
|
3747
3834
|
// Self-serve quote: match BE receipt / intent: (newTotalCents − previousTotalCents) / 100.
|
|
3748
3835
|
if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
|
|
@@ -3764,6 +3851,9 @@ export function AdminChangeBookingFlow({
|
|
|
3764
3851
|
audience: 'admin',
|
|
3765
3852
|
});
|
|
3766
3853
|
})();
|
|
3854
|
+
const changeFlowClientEstimateDue = normalizeNearZeroOwed(
|
|
3855
|
+
roundMoney(changeFlowClientEstimateDueBase + editableSummaryPreSubtotalDelta)
|
|
3856
|
+
);
|
|
3767
3857
|
|
|
3768
3858
|
const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
|
|
3769
3859
|
const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
|
|
@@ -3797,18 +3887,33 @@ export function AdminChangeBookingFlow({
|
|
|
3797
3887
|
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-800">
|
|
3798
3888
|
{[
|
|
3799
3889
|
`Amount-due path: ${path}`,
|
|
3890
|
+
`adminFeAuthoritativeQuoteEnabled: ${String(useAdminFeAuthoritativeQuote)}`,
|
|
3800
3891
|
`displayLayerUsesExternalPricing: ${String(displayLayerUsesExternalPricing)} (server/provider totals omit FE admin lines until overlaid)`,
|
|
3801
3892
|
`selfServePricingConfirmed: ${String(selfServePricingConfirmed)} · changeQuoteLoading: ${String(changeQuoteLoading)}`,
|
|
3802
3893
|
`Cart subtotal (excl. admin custom lines): ${fmt(effectiveSubtotal)}`,
|
|
3803
3894
|
`Admin custom lines (sum): ${fmt(adminCustomAdjustmentTotal)}`,
|
|
3895
|
+
`Pre-subtotal line overrides:`,
|
|
3896
|
+
...editableSummaryPreSubtotalDebugRows.map(
|
|
3897
|
+
(row, idx) =>
|
|
3898
|
+
` ${idx + 1}. ${row.label} | baseline ${fmt(row.baseline)} -> edited ${fmt(row.edited)} (delta ${fmt(row.delta)})`
|
|
3899
|
+
),
|
|
3804
3900
|
`Checkout subtotal (incl. admin lines): ${fmt(effectiveSubtotalForCheckout)}`,
|
|
3805
3901
|
`effectiveTax · promo discount: ${fmt(effectiveTax)} · ${fmt(effectivePromoDiscountAmount)}`,
|
|
3806
3902
|
`totalPrice (subtotal + tax − promo): ${fmt(totalPrice)}`,
|
|
3807
3903
|
`changeFlowNewBookingTotal (after cent reconcile vs receipt): ${fmt(changeFlowNewBookingTotal)}`,
|
|
3808
3904
|
`Displayed layer subtotal / tax / total: ${fmt(displayedChangeAmountsRaw.subtotal)} / ${fmt(displayedChangeAmountsRaw.tax)} / ${fmt(displayedChangeAmountsRaw.total)}`,
|
|
3809
|
-
`
|
|
3905
|
+
`Pre-subtotal editable lines delta: ${fmt(editableSummaryPreSubtotalDelta)}`,
|
|
3906
|
+
`displayChangeFlowProposedTotal (base): ${fmt(displayChangeFlowProposedTotal)}`,
|
|
3907
|
+
`displayChangeFlowProposedTotal (with editable lines): ${fmt(displayChangeFlowProposedTotalWithEditableLines)}`,
|
|
3908
|
+
`feReceipt sent subtotal/tax/total: ${fmt(adminFeAuthoritativeReceipt.subtotal)} / ${fmt(adminFeAuthoritativeReceipt.tax)} / ${fmt(adminFeAuthoritativeReceipt.total)}`,
|
|
3909
|
+
`feReceipt sent lineItems count: ${String(adminFeAuthoritativeReceipt.lineItems.length)}`,
|
|
3810
3910
|
originalReceipt ? `originalReceipt.total: ${fmt(originalReceipt.total)}` : 'originalReceipt: —',
|
|
3811
3911
|
`Quote serverDisplay.total (if any): ${quoteTotalPreview}`,
|
|
3912
|
+
`Quote serverDisplay subtotal/tax/total: ${
|
|
3913
|
+
latestChangeQuote?.serverDisplay
|
|
3914
|
+
? `${fmt(latestChangeQuote.serverDisplay.subtotal)} / ${fmt(latestChangeQuote.serverDisplay.tax)} / ${fmt(latestChangeQuote.serverDisplay.total)}`
|
|
3915
|
+
: '—'
|
|
3916
|
+
}`,
|
|
3812
3917
|
`changeFlowClientEstimateDue (before near-zero): ${fmt(changeFlowClientEstimateDue)}`,
|
|
3813
3918
|
`changeFlowAmountDue (PriceSummary total row): ${fmt(changeFlowAmountDue)}`,
|
|
3814
3919
|
].join('\n')}
|
|
@@ -3819,6 +3924,7 @@ export function AdminChangeBookingFlow({
|
|
|
3819
3924
|
isAdmin,
|
|
3820
3925
|
selectedAvailability,
|
|
3821
3926
|
totalQuantity,
|
|
3927
|
+
useAdminFeAuthoritativeQuote,
|
|
3822
3928
|
currency,
|
|
3823
3929
|
locale,
|
|
3824
3930
|
originalReceipt,
|
|
@@ -3830,6 +3936,7 @@ export function AdminChangeBookingFlow({
|
|
|
3830
3936
|
changeQuoteLoading,
|
|
3831
3937
|
effectiveSubtotal,
|
|
3832
3938
|
adminCustomAdjustmentTotal,
|
|
3939
|
+
editableSummaryPreSubtotalDebugRows,
|
|
3833
3940
|
effectiveSubtotalForCheckout,
|
|
3834
3941
|
effectiveTax,
|
|
3835
3942
|
effectivePromoDiscountAmount,
|
|
@@ -3838,7 +3945,10 @@ export function AdminChangeBookingFlow({
|
|
|
3838
3945
|
displayedChangeAmountsRaw.subtotal,
|
|
3839
3946
|
displayedChangeAmountsRaw.tax,
|
|
3840
3947
|
displayedChangeAmountsRaw.total,
|
|
3948
|
+
editableSummaryPreSubtotalDelta,
|
|
3841
3949
|
displayChangeFlowProposedTotal,
|
|
3950
|
+
displayChangeFlowProposedTotalWithEditableLines,
|
|
3951
|
+
adminFeAuthoritativeReceipt,
|
|
3842
3952
|
changeFlowClientEstimateDue,
|
|
3843
3953
|
changeFlowAmountDue,
|
|
3844
3954
|
]);
|
|
@@ -3966,7 +4076,7 @@ export function AdminChangeBookingFlow({
|
|
|
3966
4076
|
originalReceipt
|
|
3967
4077
|
? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
|
|
3968
4078
|
? null
|
|
3969
|
-
:
|
|
4079
|
+
: displayChangeFlowProposedTotalWithEditableLines
|
|
3970
4080
|
: totalPrice,
|
|
3971
4081
|
selectionCurrency: currency,
|
|
3972
4082
|
};
|
|
@@ -3980,7 +4090,7 @@ export function AdminChangeBookingFlow({
|
|
|
3980
4090
|
totalPrice,
|
|
3981
4091
|
currency,
|
|
3982
4092
|
originalReceipt,
|
|
3983
|
-
|
|
4093
|
+
displayChangeFlowProposedTotalWithEditableLines,
|
|
3984
4094
|
suppressSelfServeCurrencyUi,
|
|
3985
4095
|
selfServePricingConfirmed,
|
|
3986
4096
|
]);
|
|
@@ -4018,14 +4128,14 @@ export function AdminChangeBookingFlow({
|
|
|
4018
4128
|
return;
|
|
4019
4129
|
}
|
|
4020
4130
|
onPricePreviewChange({
|
|
4021
|
-
subtotal: originalReceipt ?
|
|
4131
|
+
subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
|
|
4022
4132
|
tax:
|
|
4023
4133
|
!isTaxIncludedInPrice
|
|
4024
4134
|
? originalReceipt
|
|
4025
|
-
?
|
|
4135
|
+
? adminFeAuthoritativeReceipt.tax
|
|
4026
4136
|
: effectiveTax
|
|
4027
4137
|
: 0,
|
|
4028
|
-
total: originalReceipt ?
|
|
4138
|
+
total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
|
|
4029
4139
|
currency,
|
|
4030
4140
|
});
|
|
4031
4141
|
}, [
|
|
@@ -4034,9 +4144,9 @@ export function AdminChangeBookingFlow({
|
|
|
4034
4144
|
totalQuantity,
|
|
4035
4145
|
effectiveSubtotalForCheckout,
|
|
4036
4146
|
effectiveTax,
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4147
|
+
displayChangeFlowProposedTotalWithEditableLines,
|
|
4148
|
+
adminFeAuthoritativeReceipt.subtotal,
|
|
4149
|
+
adminFeAuthoritativeReceipt.tax,
|
|
4040
4150
|
currency,
|
|
4041
4151
|
isTaxIncludedInPrice,
|
|
4042
4152
|
originalReceipt,
|
|
@@ -4086,7 +4196,7 @@ export function AdminChangeBookingFlow({
|
|
|
4086
4196
|
.filter(([, count]) => count > 0)
|
|
4087
4197
|
.map(([category, count]) => ({ category, count }));
|
|
4088
4198
|
try {
|
|
4089
|
-
const
|
|
4199
|
+
const quoteRequestBase = {
|
|
4090
4200
|
bookingReference: bookingReferenceForQuote,
|
|
4091
4201
|
lastName: lastName.trim(),
|
|
4092
4202
|
newProductId: optionId,
|
|
@@ -4107,7 +4217,14 @@ export function AdminChangeBookingFlow({
|
|
|
4107
4217
|
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
4108
4218
|
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
4109
4219
|
},
|
|
4110
|
-
}
|
|
4220
|
+
};
|
|
4221
|
+
const quote = useAdminFeAuthoritativeQuote
|
|
4222
|
+
? await quoteChangeBookingAdminFeReceipt({
|
|
4223
|
+
...quoteRequestBase,
|
|
4224
|
+
feReceipt: adminFeAuthoritativeReceipt,
|
|
4225
|
+
feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
|
|
4226
|
+
})
|
|
4227
|
+
: await quoteChangeBooking(quoteRequestBase);
|
|
4111
4228
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
4112
4229
|
const slice = sliceChangeQuoteForUi(
|
|
4113
4230
|
quote,
|
|
@@ -4155,6 +4272,7 @@ export function AdminChangeBookingFlow({
|
|
|
4155
4272
|
addOnSelections,
|
|
4156
4273
|
changeFlowInitialTicketCount,
|
|
4157
4274
|
changeFlowNewBookingTotal,
|
|
4275
|
+
changeFlowClientEstimateDue,
|
|
4158
4276
|
effectiveSubtotalForCheckout,
|
|
4159
4277
|
effectiveTax,
|
|
4160
4278
|
totalPrice,
|
|
@@ -4163,6 +4281,8 @@ export function AdminChangeBookingFlow({
|
|
|
4163
4281
|
initialValues?.availabilityId,
|
|
4164
4282
|
initialValues?.returnAvailabilityId,
|
|
4165
4283
|
adminCustomLinesAsAdditionalAdjustments,
|
|
4284
|
+
adminFeAuthoritativeReceipt,
|
|
4285
|
+
useAdminFeAuthoritativeQuote,
|
|
4166
4286
|
]);
|
|
4167
4287
|
|
|
4168
4288
|
// Auto-select product option when date is selected: most popular if set, otherwise first available.
|
|
@@ -4683,7 +4803,7 @@ export function AdminChangeBookingFlow({
|
|
|
4683
4803
|
if (!changeBookingReference || !changeLastName) {
|
|
4684
4804
|
throw new Error('Missing booking reference or last name for change quote');
|
|
4685
4805
|
}
|
|
4686
|
-
const
|
|
4806
|
+
const quoteRequestBase = {
|
|
4687
4807
|
bookingReference: changeBookingReference,
|
|
4688
4808
|
lastName: changeLastName,
|
|
4689
4809
|
newProductId: availabilityProductOptionId,
|
|
@@ -4698,7 +4818,14 @@ export function AdminChangeBookingFlow({
|
|
|
4698
4818
|
: {}),
|
|
4699
4819
|
clientProposedTotal:
|
|
4700
4820
|
latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
|
|
4701
|
-
}
|
|
4821
|
+
};
|
|
4822
|
+
const quote = useAdminFeAuthoritativeQuote
|
|
4823
|
+
? await quoteChangeBookingAdminFeReceipt({
|
|
4824
|
+
...quoteRequestBase,
|
|
4825
|
+
feReceipt: adminFeAuthoritativeReceipt,
|
|
4826
|
+
feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
|
|
4827
|
+
})
|
|
4828
|
+
: await quoteChangeBooking(quoteRequestBase);
|
|
4702
4829
|
const quoteSlice = sliceChangeQuoteForUi(
|
|
4703
4830
|
quote,
|
|
4704
4831
|
{
|
|
@@ -5052,7 +5179,7 @@ export function AdminChangeBookingFlow({
|
|
|
5052
5179
|
isCustomerSelfServeChange && originalReceipt
|
|
5053
5180
|
? {
|
|
5054
5181
|
previousTotal: originalReceipt.total,
|
|
5055
|
-
newTotal:
|
|
5182
|
+
newTotal: displayChangeFlowProposedTotalWithEditableLines,
|
|
5056
5183
|
differenceTotal: amountDueForCheckout,
|
|
5057
5184
|
}
|
|
5058
5185
|
: undefined,
|
|
@@ -5158,7 +5285,7 @@ export function AdminChangeBookingFlow({
|
|
|
5158
5285
|
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
5159
5286
|
cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
|
|
5160
5287
|
promoCode: appliedPromoCode ?? null,
|
|
5161
|
-
newTotalAmount:
|
|
5288
|
+
newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
|
|
5162
5289
|
additionalHoursCount: null,
|
|
5163
5290
|
pricingAdjustment:
|
|
5164
5291
|
providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
|
|
@@ -5531,8 +5658,8 @@ export function AdminChangeBookingFlow({
|
|
|
5531
5658
|
{selectedAvailability && (
|
|
5532
5659
|
<>
|
|
5533
5660
|
<CheckoutForm
|
|
5534
|
-
priceSummaryLines={
|
|
5535
|
-
replacePriceSummary={selfServeCheckoutPlaceholder}
|
|
5661
|
+
priceSummaryLines={editableCheckoutPriceSummaryLines}
|
|
5662
|
+
replacePriceSummary={hasChangeSelection ? selfServeCheckoutPlaceholder : undefined}
|
|
5536
5663
|
totalPrice={changeFlowAmountDue}
|
|
5537
5664
|
totalSummaryLabel={
|
|
5538
5665
|
changeFlowAmountDue < -0.005
|
|
@@ -5542,12 +5669,12 @@ export function AdminChangeBookingFlow({
|
|
|
5542
5669
|
? t('booking.totalOwedForBookingChange')
|
|
5543
5670
|
: 'Total owed for booking difference'
|
|
5544
5671
|
}
|
|
5545
|
-
subtotal={
|
|
5672
|
+
subtotal={adminFeAuthoritativeReceipt.subtotal}
|
|
5546
5673
|
taxAmount={
|
|
5547
5674
|
!isTaxIncludedInPrice &&
|
|
5548
|
-
|
|
5675
|
+
adminFeAuthoritativeReceipt.tax > 0 &&
|
|
5549
5676
|
!priceSummaryLinesIncludeTaxRow
|
|
5550
|
-
?
|
|
5677
|
+
? adminFeAuthoritativeReceipt.tax
|
|
5551
5678
|
: 0
|
|
5552
5679
|
}
|
|
5553
5680
|
taxRate={pricingConfig?.taxRate}
|
|
@@ -5573,9 +5700,11 @@ export function AdminChangeBookingFlow({
|
|
|
5573
5700
|
</>
|
|
5574
5701
|
}
|
|
5575
5702
|
extraBeforeSubtotal={
|
|
5576
|
-
showChangeFlowManualPriceLines ? (
|
|
5703
|
+
(showChangeFlowManualPriceLines || showAdminCustomLineEditor) ? (
|
|
5577
5704
|
<>
|
|
5578
|
-
{
|
|
5705
|
+
{showChangeFlowManualPriceLines &&
|
|
5706
|
+
showProviderPricingInlineEditor &&
|
|
5707
|
+
(providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
5579
5708
|
<div className="space-y-1">
|
|
5580
5709
|
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
5581
5710
|
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
@@ -5589,7 +5718,7 @@ export function AdminChangeBookingFlow({
|
|
|
5589
5718
|
</button>
|
|
5590
5719
|
<input
|
|
5591
5720
|
type="text"
|
|
5592
|
-
className="w-40 rounded border border-stone-300
|
|
5721
|
+
className="admin-custom-receipt-input w-40 rounded border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
|
|
5593
5722
|
placeholder="Line description"
|
|
5594
5723
|
value={adj.label}
|
|
5595
5724
|
onChange={(e) =>
|
|
@@ -5613,7 +5742,7 @@ export function AdminChangeBookingFlow({
|
|
|
5613
5742
|
<input
|
|
5614
5743
|
type="text"
|
|
5615
5744
|
inputMode="decimal"
|
|
5616
|
-
className="
|
|
5745
|
+
className="admin-custom-receipt-input-amount w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
|
|
5617
5746
|
placeholder="0.00"
|
|
5618
5747
|
value={adj.amountInput}
|
|
5619
5748
|
onChange={(e) =>
|
|
@@ -5633,7 +5762,7 @@ export function AdminChangeBookingFlow({
|
|
|
5633
5762
|
+ add line item
|
|
5634
5763
|
</button>
|
|
5635
5764
|
</div>
|
|
5636
|
-
) : showProviderPricingInlineEditor ? (
|
|
5765
|
+
) : showChangeFlowManualPriceLines && showProviderPricingInlineEditor ? (
|
|
5637
5766
|
<button
|
|
5638
5767
|
type="button"
|
|
5639
5768
|
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
@@ -5642,6 +5771,7 @@ export function AdminChangeBookingFlow({
|
|
|
5642
5771
|
+ add line item
|
|
5643
5772
|
</button>
|
|
5644
5773
|
) : null}
|
|
5774
|
+
{showAdminCustomLineEditor ? (
|
|
5645
5775
|
<div className="space-y-3">
|
|
5646
5776
|
<div className="flex justify-center">
|
|
5647
5777
|
<button
|
|
@@ -5755,6 +5885,7 @@ export function AdminChangeBookingFlow({
|
|
|
5755
5885
|
);
|
|
5756
5886
|
})}
|
|
5757
5887
|
</div>
|
|
5888
|
+
) : null}
|
|
5758
5889
|
</>
|
|
5759
5890
|
) : null
|
|
5760
5891
|
}
|
|
@@ -5820,16 +5951,11 @@ export function AdminChangeBookingFlow({
|
|
|
5820
5951
|
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
5821
5952
|
attributionConfirmed={partnerAttributionConfirmed}
|
|
5822
5953
|
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
5823
|
-
lineAmountInputs={
|
|
5824
|
-
onLineAmountInputChange={
|
|
5825
|
-
|
|
5826
|
-
}
|
|
5827
|
-
|
|
5828
|
-
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
|
|
5829
|
-
}
|
|
5830
|
-
onLineAmountReset={
|
|
5831
|
-
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5832
|
-
}
|
|
5954
|
+
lineAmountInputs={editableSummaryLineAmountInputs}
|
|
5955
|
+
onLineAmountInputChange={handleEditableLineAmountInputChange}
|
|
5956
|
+
onLineAmountInputBlur={handleEditableLineAmountInputBlur}
|
|
5957
|
+
onLineAmountReset={handleEditableLineAmountReset}
|
|
5958
|
+
lineLabelInputs={editableSummaryLineLabelInputs}
|
|
5833
5959
|
/>
|
|
5834
5960
|
</>
|
|
5835
5961
|
)}
|
|
@@ -32,6 +32,9 @@ interface CheckoutFormProps {
|
|
|
32
32
|
onLineAmountInputChange?: (lineKey: string, value: string) => void;
|
|
33
33
|
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
34
34
|
onLineAmountReset?: (lineKey: string) => void;
|
|
35
|
+
/** Optional map + handler for inline editable labels in PriceSummary. */
|
|
36
|
+
lineLabelInputs?: Record<string, string>;
|
|
37
|
+
onLineLabelInputChange?: (lineKey: string, value: string) => void;
|
|
35
38
|
extraBeforeSubtotal?: ReactNode;
|
|
36
39
|
// Promo (passed as extraBetweenTaxAndTotal content - we'll keep it in parent for now)
|
|
37
40
|
// Contact info
|
|
@@ -95,6 +98,8 @@ export function CheckoutForm({
|
|
|
95
98
|
onLineAmountInputChange,
|
|
96
99
|
onLineAmountInputBlur,
|
|
97
100
|
onLineAmountReset,
|
|
101
|
+
lineLabelInputs,
|
|
102
|
+
onLineLabelInputChange,
|
|
98
103
|
extraBeforeSubtotal,
|
|
99
104
|
firstName,
|
|
100
105
|
lastName,
|
|
@@ -168,6 +173,8 @@ export function CheckoutForm({
|
|
|
168
173
|
onLineAmountInputChange={onLineAmountInputChange}
|
|
169
174
|
onLineAmountInputBlur={onLineAmountInputBlur}
|
|
170
175
|
onLineAmountReset={onLineAmountReset}
|
|
176
|
+
lineLabelInputs={lineLabelInputs}
|
|
177
|
+
onLineLabelInputChange={onLineLabelInputChange}
|
|
171
178
|
extraBeforeSubtotal={extraBeforeSubtotal}
|
|
172
179
|
/>
|
|
173
180
|
)}
|
|
@@ -20,6 +20,8 @@ export interface PriceBreakdownProps {
|
|
|
20
20
|
onEditableChange?: (value: string) => void;
|
|
21
21
|
onEditableBlur?: () => void;
|
|
22
22
|
onEditableReset?: () => void;
|
|
23
|
+
editableLabelValue?: string;
|
|
24
|
+
onEditableLabelChange?: (value: string) => void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -65,6 +67,8 @@ export function PriceBreakdown({
|
|
|
65
67
|
onEditableChange,
|
|
66
68
|
onEditableBlur,
|
|
67
69
|
onEditableReset,
|
|
70
|
+
editableLabelValue,
|
|
71
|
+
onEditableLabelChange,
|
|
68
72
|
}: PriceBreakdownProps) {
|
|
69
73
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
70
74
|
const { t } = useTranslations();
|
|
@@ -81,9 +85,19 @@ export function PriceBreakdown({
|
|
|
81
85
|
if (!breakdown) {
|
|
82
86
|
return (
|
|
83
87
|
<div className="flex items-center justify-between">
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
{editable && onEditableLabelChange ? (
|
|
89
|
+
<input
|
|
90
|
+
type="text"
|
|
91
|
+
className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
|
|
92
|
+
value={editableLabelValue ?? category}
|
|
93
|
+
onChange={(e) => onEditableLabelChange(e.target.value)}
|
|
94
|
+
aria-label={`Edit ${category} label`}
|
|
95
|
+
/>
|
|
96
|
+
) : (
|
|
97
|
+
<span className="text-sm text-stone-600">
|
|
98
|
+
{category} {qty > 1 ? `× ${qty}` : ''}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
87
101
|
{editable && onEditableChange ? (
|
|
88
102
|
<div className="flex items-center gap-1">
|
|
89
103
|
{onEditableReset ? (
|
|
@@ -101,7 +115,7 @@ export function PriceBreakdown({
|
|
|
101
115
|
<input
|
|
102
116
|
type="text"
|
|
103
117
|
inputMode="decimal"
|
|
104
|
-
className="
|
|
118
|
+
className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
|
|
105
119
|
value={editableValue ?? String(itemTotal)}
|
|
106
120
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
107
121
|
onBlur={onEditableBlur}
|
|
@@ -118,9 +132,19 @@ export function PriceBreakdown({
|
|
|
118
132
|
|
|
119
133
|
return (
|
|
120
134
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
{editable && onEditableLabelChange ? (
|
|
136
|
+
<input
|
|
137
|
+
type="text"
|
|
138
|
+
className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
|
|
139
|
+
value={editableLabelValue ?? category}
|
|
140
|
+
onChange={(e) => onEditableLabelChange(e.target.value)}
|
|
141
|
+
aria-label={`Edit ${category} label`}
|
|
142
|
+
/>
|
|
143
|
+
) : (
|
|
144
|
+
<span className="text-sm text-stone-600 min-w-0 truncate">
|
|
145
|
+
{category} {qty > 1 ? `× ${qty}` : ''}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
124
148
|
<div className="relative flex-shrink-0 whitespace-nowrap">
|
|
125
149
|
{editable && onEditableChange ? (
|
|
126
150
|
<div className="flex items-center gap-1">
|
|
@@ -139,7 +163,7 @@ export function PriceBreakdown({
|
|
|
139
163
|
<input
|
|
140
164
|
type="text"
|
|
141
165
|
inputMode="decimal"
|
|
142
|
-
className="
|
|
166
|
+
className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
|
|
143
167
|
value={editableValue ?? String(itemTotal)}
|
|
144
168
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
145
169
|
onBlur={onEditableBlur}
|
|
@@ -73,8 +73,12 @@ export interface PriceSummaryProps {
|
|
|
73
73
|
extraAfterTotal?: ReactNode;
|
|
74
74
|
/** Optional map of editable line input values by line key. */
|
|
75
75
|
lineAmountInputs?: Record<string, string>;
|
|
76
|
+
/** Optional map of editable line label values by line key. */
|
|
77
|
+
lineLabelInputs?: Record<string, string>;
|
|
76
78
|
/** Optional change handler for inline editable line amounts. */
|
|
77
79
|
onLineAmountInputChange?: (lineKey: string, value: string) => void;
|
|
80
|
+
/** Optional change handler for inline editable line labels. */
|
|
81
|
+
onLineLabelInputChange?: (lineKey: string, value: string) => void;
|
|
78
82
|
/** Optional blur handler for enforcing display format (e.g. 2 decimals). */
|
|
79
83
|
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
80
84
|
/** Optional reset handler for inline editable line amounts. */
|
|
@@ -102,6 +106,24 @@ function formatLineAmount(
|
|
|
102
106
|
return formatted;
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
function shouldAppendQuantitySuffix(type: string | undefined, label: string, quantity: number | null | undefined): boolean {
|
|
110
|
+
if (quantity == null || quantity <= 1) return false;
|
|
111
|
+
const normalizedType = String(type ?? '').trim().toUpperCase();
|
|
112
|
+
const eligibleType =
|
|
113
|
+
normalizedType === 'TICKET' ||
|
|
114
|
+
normalizedType === 'FEE' ||
|
|
115
|
+
normalizedType === 'RETURN_OPTION' ||
|
|
116
|
+
normalizedType === 'RETURNOPTION' ||
|
|
117
|
+
normalizedType === 'RETURN';
|
|
118
|
+
if (!eligibleType) return false;
|
|
119
|
+
const normalizedLabel = label.toLowerCase();
|
|
120
|
+
const alreadyHasCountHint =
|
|
121
|
+
/\bx\s*\d+\b/.test(normalizedLabel) ||
|
|
122
|
+
/\(\s*x\s*\d+\s*\)/.test(normalizedLabel) ||
|
|
123
|
+
/\b\d+\s*(person|people|pax|traveler|travellers|ticket|tickets)\b/.test(normalizedLabel);
|
|
124
|
+
return !alreadyHasCountHint;
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
function getCurrencySymbol(currency: Currency, locale: Locale): string {
|
|
106
128
|
return formatCurrencyAmount(0, currency, locale).replace(/[0-9\s,.-]/g, '') || currency;
|
|
107
129
|
}
|
|
@@ -134,7 +156,9 @@ export function PriceSummary({
|
|
|
134
156
|
totalLabel,
|
|
135
157
|
extraAfterTotal,
|
|
136
158
|
lineAmountInputs,
|
|
159
|
+
lineLabelInputs,
|
|
137
160
|
onLineAmountInputChange,
|
|
161
|
+
onLineLabelInputChange,
|
|
138
162
|
onLineAmountInputBlur,
|
|
139
163
|
onLineAmountReset,
|
|
140
164
|
}: PriceSummaryProps) {
|
|
@@ -161,6 +185,7 @@ export function PriceSummary({
|
|
|
161
185
|
return (
|
|
162
186
|
<div className={`space-y-2 min-w-0 ${className}`}>
|
|
163
187
|
{lines.map((row, index) => {
|
|
188
|
+
const isBeforeSubtotalBoundary = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
|
|
164
189
|
if (row.kind === 'ticket') {
|
|
165
190
|
return (
|
|
166
191
|
<PriceBreakdown
|
|
@@ -171,23 +196,29 @@ export function PriceSummary({
|
|
|
171
196
|
breakdown={row.breakdown ?? null}
|
|
172
197
|
currency={currency}
|
|
173
198
|
locale={locale}
|
|
174
|
-
editable={row.editable}
|
|
199
|
+
editable={Boolean(row.editable && isBeforeSubtotalBoundary)}
|
|
175
200
|
editableValue={row.lineKey ? lineAmountInputs?.[row.lineKey] : undefined}
|
|
176
201
|
onEditableChange={
|
|
177
|
-
row.editable && row.lineKey && onLineAmountInputChange
|
|
202
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputChange
|
|
178
203
|
? (value) => onLineAmountInputChange(row.lineKey!, value)
|
|
179
204
|
: undefined
|
|
180
205
|
}
|
|
181
206
|
onEditableBlur={
|
|
182
|
-
row.editable && row.lineKey && onLineAmountInputBlur
|
|
207
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountInputBlur
|
|
183
208
|
? () => onLineAmountInputBlur(row.lineKey!)
|
|
184
209
|
: undefined
|
|
185
210
|
}
|
|
186
211
|
onEditableReset={
|
|
187
|
-
row.editable && row.lineKey && onLineAmountReset
|
|
212
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineAmountReset
|
|
188
213
|
? () => onLineAmountReset(row.lineKey!)
|
|
189
214
|
: undefined
|
|
190
215
|
}
|
|
216
|
+
editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
|
|
217
|
+
onEditableLabelChange={
|
|
218
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
|
|
219
|
+
? (value) => onLineLabelInputChange(row.lineKey!, value)
|
|
220
|
+
: undefined
|
|
221
|
+
}
|
|
191
222
|
/>
|
|
192
223
|
);
|
|
193
224
|
}
|
|
@@ -209,10 +240,20 @@ export function PriceSummary({
|
|
|
209
240
|
)}
|
|
210
241
|
<div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
|
|
211
242
|
<span className="text-stone-600 min-w-0 flex items-center gap-1">
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
243
|
+
{editable && isBeforeSubtotalBoundary && lineKey && onLineLabelInputChange ? (
|
|
244
|
+
<input
|
|
245
|
+
type="text"
|
|
246
|
+
className="h-7 min-w-[10rem] max-w-[16rem] rounded border border-stone-300 bg-white px-2 text-sm text-stone-700"
|
|
247
|
+
value={lineLabelInputs?.[lineKey] ?? label}
|
|
248
|
+
onChange={(e) => onLineLabelInputChange(lineKey, e.target.value)}
|
|
249
|
+
aria-label={`Edit ${label} label`}
|
|
250
|
+
/>
|
|
251
|
+
) : (
|
|
252
|
+
<span className="min-w-0 truncate">
|
|
253
|
+
{label}
|
|
254
|
+
{shouldAppendQuantitySuffix(type, label, quantity) ? ` (x${quantity})` : ''}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
216
257
|
{tooltip && <InfoTooltip text={tooltip} />}
|
|
217
258
|
</span>
|
|
218
259
|
{editable && lineKey && onLineAmountInputChange ? (
|
|
@@ -232,7 +273,7 @@ export function PriceSummary({
|
|
|
232
273
|
<input
|
|
233
274
|
type="text"
|
|
234
275
|
inputMode="decimal"
|
|
235
|
-
className="
|
|
276
|
+
className="admin-editable-summary-amount-input w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
|
|
236
277
|
value={lineAmountInputs?.[lineKey] ?? String(amount)}
|
|
237
278
|
onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
|
|
238
279
|
onBlur={() => onLineAmountInputBlur?.(lineKey)}
|
|
@@ -65,6 +65,11 @@ export interface BookingFlowUiOptions {
|
|
|
65
65
|
itineraryStickyTopOffsetPx?: number;
|
|
66
66
|
/** Provider dashboard change flow: quote/override panel shown immediately before confirm CTA. */
|
|
67
67
|
providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
|
|
68
|
+
/**
|
|
69
|
+
* Admin/provider-dashboard change flow: when true, use the admin FE-authoritative quote API.
|
|
70
|
+
* This is a rollout flag; public self-serve must stay on the existing public quote endpoint.
|
|
71
|
+
*/
|
|
72
|
+
adminFeAuthoritativeQuoteEnabled?: boolean;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
|
@@ -293,6 +293,14 @@
|
|
|
293
293
|
padding: 0.5rem 0.75rem;
|
|
294
294
|
min-height: 2.75rem;
|
|
295
295
|
}
|
|
296
|
+
.booking-flow-preflight input.admin-editable-summary-amount-input[type='text'] {
|
|
297
|
+
box-sizing: border-box;
|
|
298
|
+
padding: 0.5rem 0.75rem;
|
|
299
|
+
min-height: 2.75rem;
|
|
300
|
+
font-size: 0.875rem;
|
|
301
|
+
line-height: 1.25rem;
|
|
302
|
+
font-weight: 500;
|
|
303
|
+
}
|
|
296
304
|
.booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
|
|
297
305
|
box-sizing: border-box;
|
|
298
306
|
padding: 0.5rem 0.65rem;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type { PriceSummaryLine } from './PriceSummary';
|
|
3
|
+
import { roundMoney } from '../../lib/booking/change-flow-pricing';
|
|
4
|
+
|
|
5
|
+
type EditableSummaryLineInputState = {
|
|
6
|
+
amountInput: string;
|
|
7
|
+
labelInput: string;
|
|
8
|
+
amountDirty: boolean;
|
|
9
|
+
labelDirty: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ProviderAmountSync = {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
onChange?: (lineKey: string, value: string) => void;
|
|
15
|
+
onBlur?: (lineKey: string) => void;
|
|
16
|
+
onReset?: (lineKey: string) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getEditableSummaryLineKey(line: PriceSummaryLine, index: number): string {
|
|
20
|
+
if (line.lineKey) return line.lineKey;
|
|
21
|
+
return line.kind === 'ticket'
|
|
22
|
+
? `change-ticket-${line.category}-${index}`
|
|
23
|
+
: `change-line-${line.label}-${line.type ?? 'line'}-${index}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useEditableSummaryLines(
|
|
27
|
+
checkoutPriceSummaryLinesForCheckout: PriceSummaryLine[],
|
|
28
|
+
providerAmountSync: ProviderAmountSync,
|
|
29
|
+
) {
|
|
30
|
+
const [editableSummaryInputsByKey, setEditableSummaryInputsByKey] = useState<
|
|
31
|
+
Record<string, EditableSummaryLineInputState>
|
|
32
|
+
>({});
|
|
33
|
+
|
|
34
|
+
const editableSummaryLineAmountInputs = useMemo(() => {
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
for (const [lineKey, state] of Object.entries(editableSummaryInputsByKey)) {
|
|
37
|
+
out[lineKey] = state.amountInput;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}, [editableSummaryInputsByKey]);
|
|
41
|
+
|
|
42
|
+
const editableSummaryLineLabelInputs = useMemo(() => {
|
|
43
|
+
const out: Record<string, string> = {};
|
|
44
|
+
for (const [lineKey, state] of Object.entries(editableSummaryInputsByKey)) {
|
|
45
|
+
out[lineKey] = state.labelInput;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}, [editableSummaryInputsByKey]);
|
|
49
|
+
|
|
50
|
+
const editableCheckoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
51
|
+
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
52
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
53
|
+
);
|
|
54
|
+
return checkoutPriceSummaryLinesForCheckout.map((line, index) => {
|
|
55
|
+
const isBeforeSubtotal = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
|
|
56
|
+
const lineKey = getEditableSummaryLineKey(line, index);
|
|
57
|
+
const lineInputState = editableSummaryInputsByKey[lineKey];
|
|
58
|
+
if (line.kind === 'ticket') {
|
|
59
|
+
const amountInput = lineInputState?.amountInput;
|
|
60
|
+
const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.itemTotal : Number(amountInput);
|
|
61
|
+
return {
|
|
62
|
+
...line,
|
|
63
|
+
lineKey,
|
|
64
|
+
editable: isBeforeSubtotal,
|
|
65
|
+
category: lineInputState?.labelInput ?? line.category,
|
|
66
|
+
itemTotal: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.itemTotal,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const amountInput = lineInputState?.amountInput;
|
|
70
|
+
const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.amount : Number(amountInput);
|
|
71
|
+
return {
|
|
72
|
+
...line,
|
|
73
|
+
lineKey,
|
|
74
|
+
editable: isBeforeSubtotal,
|
|
75
|
+
label: lineInputState?.labelInput ?? line.label,
|
|
76
|
+
amount: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.amount,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}, [checkoutPriceSummaryLinesForCheckout, editableSummaryInputsByKey]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setEditableSummaryInputsByKey((prev) => {
|
|
83
|
+
const next: Record<string, EditableSummaryLineInputState> = {};
|
|
84
|
+
for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
|
|
85
|
+
const line = checkoutPriceSummaryLinesForCheckout[i];
|
|
86
|
+
const key = getEditableSummaryLineKey(line, i);
|
|
87
|
+
const baselineAmount =
|
|
88
|
+
line.kind === 'ticket' ? String(roundMoney(line.itemTotal)) : String(roundMoney(line.amount));
|
|
89
|
+
const baselineLabel = line.kind === 'ticket' ? line.category : line.label;
|
|
90
|
+
const current = prev[key];
|
|
91
|
+
const amountDirty = current?.amountDirty === true;
|
|
92
|
+
const labelDirty = current?.labelDirty === true;
|
|
93
|
+
next[key] = {
|
|
94
|
+
amountInput: amountDirty ? (current?.amountInput ?? baselineAmount) : baselineAmount,
|
|
95
|
+
labelInput: labelDirty ? (current?.labelInput ?? baselineLabel) : baselineLabel,
|
|
96
|
+
amountDirty,
|
|
97
|
+
labelDirty,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const prevKeys = Object.keys(prev);
|
|
101
|
+
const nextKeys = Object.keys(next);
|
|
102
|
+
if (prevKeys.length !== nextKeys.length) return next;
|
|
103
|
+
const unchanged = nextKeys.every((key) => {
|
|
104
|
+
const a = prev[key];
|
|
105
|
+
const b = next[key];
|
|
106
|
+
return (
|
|
107
|
+
a?.amountInput === b.amountInput &&
|
|
108
|
+
a?.labelInput === b.labelInput &&
|
|
109
|
+
a?.amountDirty === b.amountDirty &&
|
|
110
|
+
a?.labelDirty === b.labelDirty
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
return unchanged ? prev : next;
|
|
114
|
+
});
|
|
115
|
+
}, [checkoutPriceSummaryLinesForCheckout]);
|
|
116
|
+
|
|
117
|
+
const editableSummaryPreSubtotalDelta = useMemo(() => {
|
|
118
|
+
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
119
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
120
|
+
);
|
|
121
|
+
let delta = 0;
|
|
122
|
+
for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
|
|
123
|
+
if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
|
|
124
|
+
const line = checkoutPriceSummaryLinesForCheckout[i];
|
|
125
|
+
const lineKey = getEditableSummaryLineKey(line, i);
|
|
126
|
+
const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
127
|
+
const raw = editableSummaryLineAmountInputs[lineKey];
|
|
128
|
+
const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
|
|
129
|
+
const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
|
|
130
|
+
delta += editedAmount - baselineAmount;
|
|
131
|
+
}
|
|
132
|
+
return roundMoney(delta);
|
|
133
|
+
}, [checkoutPriceSummaryLinesForCheckout, editableSummaryLineAmountInputs]);
|
|
134
|
+
|
|
135
|
+
const editableSummaryPreSubtotalDebugRows = useMemo(() => {
|
|
136
|
+
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
137
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
138
|
+
);
|
|
139
|
+
const rows: Array<{ label: string; baseline: number; edited: number; delta: number }> = [];
|
|
140
|
+
for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
|
|
141
|
+
if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
|
|
142
|
+
const line = checkoutPriceSummaryLinesForCheckout[i];
|
|
143
|
+
const lineKey = getEditableSummaryLineKey(line, i);
|
|
144
|
+
const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
145
|
+
const raw = editableSummaryLineAmountInputs[lineKey];
|
|
146
|
+
const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
|
|
147
|
+
const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
|
|
148
|
+
const delta = roundMoney(editedAmount - baselineAmount);
|
|
149
|
+
rows.push({
|
|
150
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
151
|
+
baseline: roundMoney(baselineAmount),
|
|
152
|
+
edited: editedAmount,
|
|
153
|
+
delta,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
}, [checkoutPriceSummaryLinesForCheckout, editableSummaryLineAmountInputs]);
|
|
158
|
+
|
|
159
|
+
const onLineAmountInputChange = (lineKey: string, value: string) => {
|
|
160
|
+
const sanitized = value.replace(/[^0-9.-]/g, '');
|
|
161
|
+
setEditableSummaryInputsByKey((prev) => {
|
|
162
|
+
const current = prev[lineKey] ?? {
|
|
163
|
+
amountInput: '',
|
|
164
|
+
labelInput: '',
|
|
165
|
+
amountDirty: false,
|
|
166
|
+
labelDirty: false,
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
...prev,
|
|
170
|
+
[lineKey]: { ...current, amountInput: sanitized, amountDirty: true },
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
if (providerAmountSync.enabled) providerAmountSync.onChange?.(lineKey, sanitized);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onLineAmountInputBlur = (lineKey: string) => {
|
|
177
|
+
setEditableSummaryInputsByKey((prev) => {
|
|
178
|
+
const current = prev[lineKey];
|
|
179
|
+
if (!current) return prev;
|
|
180
|
+
const raw = current.amountInput ?? '';
|
|
181
|
+
if (raw.trim() === '' || raw === '-' || raw === '.' || raw === '-.') {
|
|
182
|
+
return prev;
|
|
183
|
+
}
|
|
184
|
+
const parsed = Number(raw);
|
|
185
|
+
if (!Number.isFinite(parsed)) return prev;
|
|
186
|
+
const normalized = roundMoney(parsed).toFixed(2);
|
|
187
|
+
if (normalized === raw) return prev;
|
|
188
|
+
return {
|
|
189
|
+
...prev,
|
|
190
|
+
[lineKey]: { ...current, amountInput: normalized },
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
if (providerAmountSync.enabled) providerAmountSync.onBlur?.(lineKey);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const onLineAmountReset = (lineKey: string) => {
|
|
197
|
+
const original = checkoutPriceSummaryLinesForCheckout.find(
|
|
198
|
+
(line, index) => getEditableSummaryLineKey(line, index) === lineKey,
|
|
199
|
+
);
|
|
200
|
+
if (!original) return;
|
|
201
|
+
const nextValue =
|
|
202
|
+
original.kind === 'ticket' ? String(roundMoney(original.itemTotal)) : String(roundMoney(original.amount));
|
|
203
|
+
setEditableSummaryInputsByKey((prev) => {
|
|
204
|
+
const current = prev[lineKey] ?? {
|
|
205
|
+
amountInput: nextValue,
|
|
206
|
+
labelInput: '',
|
|
207
|
+
amountDirty: false,
|
|
208
|
+
labelDirty: false,
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
...prev,
|
|
212
|
+
[lineKey]: { ...current, amountInput: nextValue, amountDirty: false },
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
if (providerAmountSync.enabled) providerAmountSync.onReset?.(lineKey);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onLineLabelInputChange = (lineKey: string, value: string) => {
|
|
219
|
+
setEditableSummaryInputsByKey((prev) => {
|
|
220
|
+
const current = prev[lineKey] ?? {
|
|
221
|
+
amountInput: '',
|
|
222
|
+
labelInput: '',
|
|
223
|
+
amountDirty: false,
|
|
224
|
+
labelDirty: false,
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
...prev,
|
|
228
|
+
[lineKey]: { ...current, labelInput: value, labelDirty: true },
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
editableCheckoutPriceSummaryLines,
|
|
235
|
+
editableSummaryLineAmountInputs,
|
|
236
|
+
editableSummaryLineLabelInputs,
|
|
237
|
+
editableSummaryPreSubtotalDelta,
|
|
238
|
+
editableSummaryPreSubtotalDebugRows,
|
|
239
|
+
onLineAmountInputChange,
|
|
240
|
+
onLineAmountInputBlur,
|
|
241
|
+
onLineAmountReset,
|
|
242
|
+
onLineLabelInputChange,
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -870,6 +870,30 @@ export interface ChangeBookingQuoteRequest {
|
|
|
870
870
|
} | null;
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
/** FE-authored receipt payload for admin quote path (major units, booking currency). */
|
|
874
|
+
export interface AdminFeAuthoritativeReceipt {
|
|
875
|
+
subtotal: number;
|
|
876
|
+
tax: number;
|
|
877
|
+
total: number;
|
|
878
|
+
currency?: string;
|
|
879
|
+
lineItems: Array<{
|
|
880
|
+
label?: string;
|
|
881
|
+
amount?: number;
|
|
882
|
+
type?: string;
|
|
883
|
+
quantity?: number;
|
|
884
|
+
}>;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Admin quote path where FE is authoritative for receipt lines/totals.
|
|
889
|
+
* Mirrors {@link ChangeBookingQuoteRequest} selection fields and adds a full receipt payload.
|
|
890
|
+
*/
|
|
891
|
+
export interface AdminChangeBookingQuoteRequest extends ChangeBookingQuoteRequest {
|
|
892
|
+
feReceipt: AdminFeAuthoritativeReceipt;
|
|
893
|
+
/** Optional explicit signed delta shown by FE (new total − original total), major units. */
|
|
894
|
+
feAmountDueMajorUnits?: number;
|
|
895
|
+
}
|
|
896
|
+
|
|
873
897
|
export interface ChangeBookingQuoteReceipt {
|
|
874
898
|
subtotal?: number;
|
|
875
899
|
tax?: number;
|
|
@@ -1108,6 +1132,31 @@ export async function quoteChangeBooking(
|
|
|
1108
1132
|
data) as ChangeBookingQuoteResponse;
|
|
1109
1133
|
}
|
|
1110
1134
|
|
|
1135
|
+
/** Admin-only quote endpoint: FE-authored receipt totals + line items are authoritative. */
|
|
1136
|
+
export async function quoteChangeBookingAdminFeReceipt(
|
|
1137
|
+
request: AdminChangeBookingQuoteRequest
|
|
1138
|
+
): Promise<ChangeBookingQuoteResponse> {
|
|
1139
|
+
const { bookingReference, lastName, ...payload } = request;
|
|
1140
|
+
const res = await fetch(
|
|
1141
|
+
`${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/quote?lastName=${encodeURIComponent(lastName)}`,
|
|
1142
|
+
{
|
|
1143
|
+
method: 'POST',
|
|
1144
|
+
headers: getAuthHeaders(),
|
|
1145
|
+
body: JSON.stringify(payload),
|
|
1146
|
+
}
|
|
1147
|
+
);
|
|
1148
|
+
if (!res.ok) {
|
|
1149
|
+
const err = await parseJsonSafely(res);
|
|
1150
|
+
const message = isApiErrorPayload(err)
|
|
1151
|
+
? err.errorMessage || err.error || 'Failed to quote admin booking change'
|
|
1152
|
+
: 'Failed to quote admin booking change';
|
|
1153
|
+
throw new Error(message);
|
|
1154
|
+
}
|
|
1155
|
+
const data = await parseJsonSafely(res);
|
|
1156
|
+
return ((data as { data?: ChangeBookingQuoteResponse } | null)?.data ??
|
|
1157
|
+
data) as ChangeBookingQuoteResponse;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1111
1160
|
export async function createChangeBookingPaymentIntent(
|
|
1112
1161
|
changeIntentId: string
|
|
1113
1162
|
): Promise<CreateChangePaymentIntentResponse> {
|