@ticketboothapp/booking 1.2.77 → 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/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +41 -188
- package/src/components/booking/PriceBreakdown.tsx +2 -2
- package/src/components/booking/PriceSummary.tsx +20 -2
- 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/package.json
CHANGED
|
@@ -104,6 +104,7 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
|
104
104
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
105
105
|
import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
|
|
106
106
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
107
|
+
import { useEditableSummaryLines } from './useEditableSummaryLines';
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
110
|
* ## Pricing contract (customer self-serve)
|
|
@@ -755,8 +756,6 @@ export function AdminChangeBookingFlow({
|
|
|
755
756
|
const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<
|
|
756
757
|
Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
|
|
757
758
|
>([]);
|
|
758
|
-
const [editableSummaryLineAmountInputs, setEditableSummaryLineAmountInputs] = useState<Record<string, string>>({});
|
|
759
|
-
const [editableSummaryLineLabelInputs, setEditableSummaryLineLabelInputs] = useState<Record<string, string>>({});
|
|
760
759
|
const adminCustomLineIdRef = useRef(0);
|
|
761
760
|
|
|
762
761
|
// Auto-apply promo code when parent page passes one (e.g. partner pages).
|
|
@@ -3148,140 +3147,24 @@ export function AdminChangeBookingFlow({
|
|
|
3148
3147
|
),
|
|
3149
3148
|
[checkoutPriceSummaryLinesForCheckout],
|
|
3150
3149
|
);
|
|
3151
|
-
const
|
|
3152
|
-
|
|
3153
|
-
return line.kind === 'ticket'
|
|
3154
|
-
? `change-ticket-${line.category}-${index}`
|
|
3155
|
-
: `change-line-${line.label}-${line.type ?? 'line'}-${index}`;
|
|
3156
|
-
}, []);
|
|
3157
|
-
const editableCheckoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
3158
|
-
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
3159
|
-
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
|
|
3160
|
-
);
|
|
3161
|
-
return checkoutPriceSummaryLinesForCheckout.map((line, index) => {
|
|
3162
|
-
const isBeforeSubtotal = firstTaxLineIndex < 0 || index < firstTaxLineIndex;
|
|
3163
|
-
const lineKey = getEditableSummaryLineKey(line, index);
|
|
3164
|
-
if (line.kind === 'ticket') {
|
|
3165
|
-
const amountInput = editableSummaryLineAmountInputs[lineKey];
|
|
3166
|
-
const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.itemTotal : Number(amountInput);
|
|
3167
|
-
return {
|
|
3168
|
-
...line,
|
|
3169
|
-
lineKey,
|
|
3170
|
-
editable: isBeforeSubtotal,
|
|
3171
|
-
category: editableSummaryLineLabelInputs[lineKey] ?? line.category,
|
|
3172
|
-
itemTotal: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.itemTotal,
|
|
3173
|
-
};
|
|
3174
|
-
}
|
|
3175
|
-
const amountInput = editableSummaryLineAmountInputs[lineKey];
|
|
3176
|
-
const parsedAmount = amountInput == null || amountInput.trim() === '' ? line.amount : Number(amountInput);
|
|
3177
|
-
return {
|
|
3178
|
-
...line,
|
|
3179
|
-
lineKey,
|
|
3180
|
-
editable: isBeforeSubtotal,
|
|
3181
|
-
label: editableSummaryLineLabelInputs[lineKey] ?? line.label,
|
|
3182
|
-
amount: Number.isFinite(parsedAmount) ? roundMoney(parsedAmount) : line.amount,
|
|
3183
|
-
};
|
|
3184
|
-
});
|
|
3185
|
-
}, [
|
|
3186
|
-
checkoutPriceSummaryLinesForCheckout,
|
|
3150
|
+
const {
|
|
3151
|
+
editableCheckoutPriceSummaryLines,
|
|
3187
3152
|
editableSummaryLineAmountInputs,
|
|
3188
3153
|
editableSummaryLineLabelInputs,
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
const line = editableCheckoutPriceSummaryLines[i];
|
|
3196
|
-
const key = line.lineKey;
|
|
3197
|
-
if (!key) continue;
|
|
3198
|
-
if (prev[key] != null) {
|
|
3199
|
-
next[key] = prev[key];
|
|
3200
|
-
continue;
|
|
3201
|
-
}
|
|
3202
|
-
next[key] = line.kind === 'ticket' ? String(line.itemTotal) : String(line.amount);
|
|
3203
|
-
}
|
|
3204
|
-
const prevKeys = Object.keys(prev);
|
|
3205
|
-
const nextKeys = Object.keys(next);
|
|
3206
|
-
if (
|
|
3207
|
-
prevKeys.length === nextKeys.length &&
|
|
3208
|
-
nextKeys.every((key) => prev[key] === next[key])
|
|
3209
|
-
) {
|
|
3210
|
-
return prev;
|
|
3211
|
-
}
|
|
3212
|
-
return next;
|
|
3213
|
-
});
|
|
3214
|
-
setEditableSummaryLineLabelInputs((prev) => {
|
|
3215
|
-
const next: Record<string, string> = {};
|
|
3216
|
-
for (let i = 0; i < editableCheckoutPriceSummaryLines.length; i += 1) {
|
|
3217
|
-
const line = editableCheckoutPriceSummaryLines[i];
|
|
3218
|
-
const key = line.lineKey;
|
|
3219
|
-
if (!key) continue;
|
|
3220
|
-
if (prev[key] != null) {
|
|
3221
|
-
next[key] = prev[key];
|
|
3222
|
-
continue;
|
|
3223
|
-
}
|
|
3224
|
-
next[key] = line.kind === 'ticket' ? line.category : line.label;
|
|
3225
|
-
}
|
|
3226
|
-
const prevKeys = Object.keys(prev);
|
|
3227
|
-
const nextKeys = Object.keys(next);
|
|
3228
|
-
if (
|
|
3229
|
-
prevKeys.length === nextKeys.length &&
|
|
3230
|
-
nextKeys.every((key) => prev[key] === next[key])
|
|
3231
|
-
) {
|
|
3232
|
-
return prev;
|
|
3233
|
-
}
|
|
3234
|
-
return next;
|
|
3235
|
-
});
|
|
3236
|
-
}, [editableCheckoutPriceSummaryLines]);
|
|
3237
|
-
const editableSummaryPreSubtotalDelta = useMemo(() => {
|
|
3238
|
-
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
3239
|
-
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
|
|
3240
|
-
);
|
|
3241
|
-
let delta = 0;
|
|
3242
|
-
for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
|
|
3243
|
-
if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
|
|
3244
|
-
const line = checkoutPriceSummaryLinesForCheckout[i];
|
|
3245
|
-
const lineKey = getEditableSummaryLineKey(line, i);
|
|
3246
|
-
const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
3247
|
-
const raw = editableSummaryLineAmountInputs[lineKey];
|
|
3248
|
-
const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
|
|
3249
|
-
const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
|
|
3250
|
-
delta += editedAmount - baselineAmount;
|
|
3251
|
-
}
|
|
3252
|
-
return roundMoney(delta);
|
|
3253
|
-
}, [
|
|
3254
|
-
checkoutPriceSummaryLinesForCheckout,
|
|
3255
|
-
editableSummaryLineAmountInputs,
|
|
3256
|
-
getEditableSummaryLineKey,
|
|
3257
|
-
]);
|
|
3258
|
-
const editableSummaryPreSubtotalDebugRows = useMemo(() => {
|
|
3259
|
-
const firstTaxLineIndex = checkoutPriceSummaryLinesForCheckout.findIndex(
|
|
3260
|
-
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX'
|
|
3261
|
-
);
|
|
3262
|
-
const rows: Array<{ label: string; baseline: number; edited: number; delta: number }> = [];
|
|
3263
|
-
for (let i = 0; i < checkoutPriceSummaryLinesForCheckout.length; i += 1) {
|
|
3264
|
-
if (firstTaxLineIndex >= 0 && i >= firstTaxLineIndex) break;
|
|
3265
|
-
const line = checkoutPriceSummaryLinesForCheckout[i];
|
|
3266
|
-
const lineKey = getEditableSummaryLineKey(line, i);
|
|
3267
|
-
const baselineAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
3268
|
-
const raw = editableSummaryLineAmountInputs[lineKey];
|
|
3269
|
-
const parsed = raw == null || raw.trim() === '' ? baselineAmount : Number(raw);
|
|
3270
|
-
const editedAmount = Number.isFinite(parsed) ? roundMoney(parsed) : baselineAmount;
|
|
3271
|
-
const delta = roundMoney(editedAmount - baselineAmount);
|
|
3272
|
-
rows.push({
|
|
3273
|
-
label: line.kind === 'ticket' ? line.category : line.label,
|
|
3274
|
-
baseline: roundMoney(baselineAmount),
|
|
3275
|
-
edited: editedAmount,
|
|
3276
|
-
delta,
|
|
3277
|
-
});
|
|
3278
|
-
}
|
|
3279
|
-
return rows;
|
|
3280
|
-
}, [
|
|
3154
|
+
editableSummaryPreSubtotalDelta,
|
|
3155
|
+
editableSummaryPreSubtotalDebugRows,
|
|
3156
|
+
onLineAmountInputChange: handleEditableLineAmountInputChange,
|
|
3157
|
+
onLineAmountInputBlur: handleEditableLineAmountInputBlur,
|
|
3158
|
+
onLineAmountReset: handleEditableLineAmountReset,
|
|
3159
|
+
} = useEditableSummaryLines(
|
|
3281
3160
|
checkoutPriceSummaryLinesForCheckout,
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3161
|
+
{
|
|
3162
|
+
enabled: showProviderPricingInlineEditor,
|
|
3163
|
+
onChange: providerPricingUi?.onLineAmountInputChange,
|
|
3164
|
+
onBlur: providerPricingUi?.onLineAmountInputBlur,
|
|
3165
|
+
onReset: providerPricingUi?.onLineAmountReset,
|
|
3166
|
+
},
|
|
3167
|
+
);
|
|
3285
3168
|
|
|
3286
3169
|
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
3287
3170
|
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
@@ -3871,7 +3754,11 @@ export function AdminChangeBookingFlow({
|
|
|
3871
3754
|
* UI-only gate — does not alter ticket line items or receipt-floor rules.
|
|
3872
3755
|
*/
|
|
3873
3756
|
const showChangeFlowManualPriceLines =
|
|
3874
|
-
changeFlowFinalPricePending || selfServePricingConfirmed;
|
|
3757
|
+
hasEffectiveChangeSelection && (changeFlowFinalPricePending || selfServePricingConfirmed);
|
|
3758
|
+
const showAdminCustomLineEditor =
|
|
3759
|
+
isAdmin &&
|
|
3760
|
+
selectedAvailability != null &&
|
|
3761
|
+
totalQuantity > 0;
|
|
3875
3762
|
|
|
3876
3763
|
const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
|
|
3877
3764
|
providerPreview: providerTotalsPreview,
|
|
@@ -4241,11 +4128,11 @@ export function AdminChangeBookingFlow({
|
|
|
4241
4128
|
return;
|
|
4242
4129
|
}
|
|
4243
4130
|
onPricePreviewChange({
|
|
4244
|
-
subtotal: originalReceipt ?
|
|
4131
|
+
subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
|
|
4245
4132
|
tax:
|
|
4246
4133
|
!isTaxIncludedInPrice
|
|
4247
4134
|
? originalReceipt
|
|
4248
|
-
?
|
|
4135
|
+
? adminFeAuthoritativeReceipt.tax
|
|
4249
4136
|
: effectiveTax
|
|
4250
4137
|
: 0,
|
|
4251
4138
|
total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
|
|
@@ -4258,8 +4145,8 @@ export function AdminChangeBookingFlow({
|
|
|
4258
4145
|
effectiveSubtotalForCheckout,
|
|
4259
4146
|
effectiveTax,
|
|
4260
4147
|
displayChangeFlowProposedTotalWithEditableLines,
|
|
4261
|
-
|
|
4262
|
-
|
|
4148
|
+
adminFeAuthoritativeReceipt.subtotal,
|
|
4149
|
+
adminFeAuthoritativeReceipt.tax,
|
|
4263
4150
|
currency,
|
|
4264
4151
|
isTaxIncludedInPrice,
|
|
4265
4152
|
originalReceipt,
|
|
@@ -5772,7 +5659,7 @@ export function AdminChangeBookingFlow({
|
|
|
5772
5659
|
<>
|
|
5773
5660
|
<CheckoutForm
|
|
5774
5661
|
priceSummaryLines={editableCheckoutPriceSummaryLines}
|
|
5775
|
-
replacePriceSummary={selfServeCheckoutPlaceholder}
|
|
5662
|
+
replacePriceSummary={hasChangeSelection ? selfServeCheckoutPlaceholder : undefined}
|
|
5776
5663
|
totalPrice={changeFlowAmountDue}
|
|
5777
5664
|
totalSummaryLabel={
|
|
5778
5665
|
changeFlowAmountDue < -0.005
|
|
@@ -5782,12 +5669,12 @@ export function AdminChangeBookingFlow({
|
|
|
5782
5669
|
? t('booking.totalOwedForBookingChange')
|
|
5783
5670
|
: 'Total owed for booking difference'
|
|
5784
5671
|
}
|
|
5785
|
-
subtotal={
|
|
5672
|
+
subtotal={adminFeAuthoritativeReceipt.subtotal}
|
|
5786
5673
|
taxAmount={
|
|
5787
5674
|
!isTaxIncludedInPrice &&
|
|
5788
|
-
|
|
5675
|
+
adminFeAuthoritativeReceipt.tax > 0 &&
|
|
5789
5676
|
!priceSummaryLinesIncludeTaxRow
|
|
5790
|
-
?
|
|
5677
|
+
? adminFeAuthoritativeReceipt.tax
|
|
5791
5678
|
: 0
|
|
5792
5679
|
}
|
|
5793
5680
|
taxRate={pricingConfig?.taxRate}
|
|
@@ -5813,9 +5700,11 @@ export function AdminChangeBookingFlow({
|
|
|
5813
5700
|
</>
|
|
5814
5701
|
}
|
|
5815
5702
|
extraBeforeSubtotal={
|
|
5816
|
-
showChangeFlowManualPriceLines ? (
|
|
5703
|
+
(showChangeFlowManualPriceLines || showAdminCustomLineEditor) ? (
|
|
5817
5704
|
<>
|
|
5818
|
-
{
|
|
5705
|
+
{showChangeFlowManualPriceLines &&
|
|
5706
|
+
showProviderPricingInlineEditor &&
|
|
5707
|
+
(providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
5819
5708
|
<div className="space-y-1">
|
|
5820
5709
|
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
5821
5710
|
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
@@ -5829,7 +5718,7 @@ export function AdminChangeBookingFlow({
|
|
|
5829
5718
|
</button>
|
|
5830
5719
|
<input
|
|
5831
5720
|
type="text"
|
|
5832
|
-
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"
|
|
5833
5722
|
placeholder="Line description"
|
|
5834
5723
|
value={adj.label}
|
|
5835
5724
|
onChange={(e) =>
|
|
@@ -5853,7 +5742,7 @@ export function AdminChangeBookingFlow({
|
|
|
5853
5742
|
<input
|
|
5854
5743
|
type="text"
|
|
5855
5744
|
inputMode="decimal"
|
|
5856
|
-
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"
|
|
5857
5746
|
placeholder="0.00"
|
|
5858
5747
|
value={adj.amountInput}
|
|
5859
5748
|
onChange={(e) =>
|
|
@@ -5873,7 +5762,7 @@ export function AdminChangeBookingFlow({
|
|
|
5873
5762
|
+ add line item
|
|
5874
5763
|
</button>
|
|
5875
5764
|
</div>
|
|
5876
|
-
) : showProviderPricingInlineEditor ? (
|
|
5765
|
+
) : showChangeFlowManualPriceLines && showProviderPricingInlineEditor ? (
|
|
5877
5766
|
<button
|
|
5878
5767
|
type="button"
|
|
5879
5768
|
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
@@ -5882,6 +5771,7 @@ export function AdminChangeBookingFlow({
|
|
|
5882
5771
|
+ add line item
|
|
5883
5772
|
</button>
|
|
5884
5773
|
) : null}
|
|
5774
|
+
{showAdminCustomLineEditor ? (
|
|
5885
5775
|
<div className="space-y-3">
|
|
5886
5776
|
<div className="flex justify-center">
|
|
5887
5777
|
<button
|
|
@@ -5995,6 +5885,7 @@ export function AdminChangeBookingFlow({
|
|
|
5995
5885
|
);
|
|
5996
5886
|
})}
|
|
5997
5887
|
</div>
|
|
5888
|
+
) : null}
|
|
5998
5889
|
</>
|
|
5999
5890
|
) : null
|
|
6000
5891
|
}
|
|
@@ -6061,48 +5952,10 @@ export function AdminChangeBookingFlow({
|
|
|
6061
5952
|
attributionConfirmed={partnerAttributionConfirmed}
|
|
6062
5953
|
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
6063
5954
|
lineAmountInputs={editableSummaryLineAmountInputs}
|
|
6064
|
-
onLineAmountInputChange={
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
if (showProviderPricingInlineEditor) {
|
|
6068
|
-
providerPricingUi?.onLineAmountInputChange?.(lineKey, sanitized);
|
|
6069
|
-
}
|
|
6070
|
-
}}
|
|
6071
|
-
onLineAmountInputBlur={(lineKey) => {
|
|
6072
|
-
setEditableSummaryLineAmountInputs((prev) => {
|
|
6073
|
-
const raw = prev[lineKey] ?? '';
|
|
6074
|
-
if (raw.trim() === '' || raw === '-' || raw === '.'
|
|
6075
|
-
|| raw === '-.') {
|
|
6076
|
-
return prev;
|
|
6077
|
-
}
|
|
6078
|
-
const parsed = Number(raw);
|
|
6079
|
-
if (!Number.isFinite(parsed)) return prev;
|
|
6080
|
-
const normalized = roundMoney(parsed).toFixed(2);
|
|
6081
|
-
if (normalized === raw) return prev;
|
|
6082
|
-
return { ...prev, [lineKey]: normalized };
|
|
6083
|
-
});
|
|
6084
|
-
if (showProviderPricingInlineEditor) {
|
|
6085
|
-
providerPricingUi?.onLineAmountInputBlur?.(lineKey);
|
|
6086
|
-
}
|
|
6087
|
-
}}
|
|
6088
|
-
onLineAmountReset={(lineKey) => {
|
|
6089
|
-
const original = checkoutPriceSummaryLinesForCheckout.find(
|
|
6090
|
-
(line, index) => getEditableSummaryLineKey(line, index) === lineKey
|
|
6091
|
-
);
|
|
6092
|
-
if (!original) return;
|
|
6093
|
-
const nextValue =
|
|
6094
|
-
original.kind === 'ticket'
|
|
6095
|
-
? String(roundMoney(original.itemTotal))
|
|
6096
|
-
: String(roundMoney(original.amount));
|
|
6097
|
-
setEditableSummaryLineAmountInputs((prev) => ({ ...prev, [lineKey]: nextValue }));
|
|
6098
|
-
if (showProviderPricingInlineEditor) {
|
|
6099
|
-
providerPricingUi?.onLineAmountReset?.(lineKey);
|
|
6100
|
-
}
|
|
6101
|
-
}}
|
|
5955
|
+
onLineAmountInputChange={handleEditableLineAmountInputChange}
|
|
5956
|
+
onLineAmountInputBlur={handleEditableLineAmountInputBlur}
|
|
5957
|
+
onLineAmountReset={handleEditableLineAmountReset}
|
|
6102
5958
|
lineLabelInputs={editableSummaryLineLabelInputs}
|
|
6103
|
-
onLineLabelInputChange={(lineKey, value) => {
|
|
6104
|
-
setEditableSummaryLineLabelInputs((prev) => ({ ...prev, [lineKey]: value }));
|
|
6105
|
-
}}
|
|
6106
5959
|
/>
|
|
6107
5960
|
</>
|
|
6108
5961
|
)}
|
|
@@ -115,7 +115,7 @@ export function PriceBreakdown({
|
|
|
115
115
|
<input
|
|
116
116
|
type="text"
|
|
117
117
|
inputMode="decimal"
|
|
118
|
-
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"
|
|
119
119
|
value={editableValue ?? String(itemTotal)}
|
|
120
120
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
121
121
|
onBlur={onEditableBlur}
|
|
@@ -163,7 +163,7 @@ export function PriceBreakdown({
|
|
|
163
163
|
<input
|
|
164
164
|
type="text"
|
|
165
165
|
inputMode="decimal"
|
|
166
|
-
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"
|
|
167
167
|
value={editableValue ?? String(itemTotal)}
|
|
168
168
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
169
169
|
onBlur={onEditableBlur}
|
|
@@ -106,6 +106,24 @@ function formatLineAmount(
|
|
|
106
106
|
return formatted;
|
|
107
107
|
}
|
|
108
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
|
+
|
|
109
127
|
function getCurrencySymbol(currency: Currency, locale: Locale): string {
|
|
110
128
|
return formatCurrencyAmount(0, currency, locale).replace(/[0-9\s,.-]/g, '') || currency;
|
|
111
129
|
}
|
|
@@ -233,7 +251,7 @@ export function PriceSummary({
|
|
|
233
251
|
) : (
|
|
234
252
|
<span className="min-w-0 truncate">
|
|
235
253
|
{label}
|
|
236
|
-
{type
|
|
254
|
+
{shouldAppendQuantitySuffix(type, label, quantity) ? ` (x${quantity})` : ''}
|
|
237
255
|
</span>
|
|
238
256
|
)}
|
|
239
257
|
{tooltip && <InfoTooltip text={tooltip} />}
|
|
@@ -255,7 +273,7 @@ export function PriceSummary({
|
|
|
255
273
|
<input
|
|
256
274
|
type="text"
|
|
257
275
|
inputMode="decimal"
|
|
258
|
-
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"
|
|
259
277
|
value={lineAmountInputs?.[lineKey] ?? String(amount)}
|
|
260
278
|
onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
|
|
261
279
|
onBlur={() => onLineAmountInputBlur?.(lineKey)}
|
|
@@ -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
|
+
}
|