@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.77",
3
+ "version": "1.2.78",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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 getEditableSummaryLineKey = useCallback((line: PriceSummaryLine, index: number): string => {
3152
- if (line.lineKey) return line.lineKey;
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
- getEditableSummaryLineKey,
3190
- ]);
3191
- useEffect(() => {
3192
- setEditableSummaryLineAmountInputs((prev) => {
3193
- const next: Record<string, string> = {};
3194
- for (let i = 0; i < editableCheckoutPriceSummaryLines.length; i += 1) {
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
- editableSummaryLineAmountInputs,
3283
- getEditableSummaryLineKey,
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 ? displayChangeFlowSubtotal : effectiveSubtotalForCheckout,
4131
+ subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
4245
4132
  tax:
4246
4133
  !isTaxIncludedInPrice
4247
4134
  ? originalReceipt
4248
- ? displayChangeFlowTax
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
- displayChangeFlowSubtotal,
4262
- displayChangeFlowTax,
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={displayChangeFlowSubtotal}
5672
+ subtotal={adminFeAuthoritativeReceipt.subtotal}
5786
5673
  taxAmount={
5787
5674
  !isTaxIncludedInPrice &&
5788
- displayChangeFlowTax > 0 &&
5675
+ adminFeAuthoritativeReceipt.tax > 0 &&
5789
5676
  !priceSummaryLinesIncludeTaxRow
5790
- ? displayChangeFlowTax
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
- {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
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 px-2 py-0.5 text-sm"
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="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
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={(lineKey, value) => {
6065
- const sanitized = value.replace(/[^0-9.-]/g, '');
6066
- setEditableSummaryLineAmountInputs((prev) => ({ ...prev, [lineKey]: sanitized }));
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="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
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="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
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 === 'TICKET' && quantity != null && quantity > 1 ? ` (x${quantity})` : ''}
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="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
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
+ }
@@ -97,6 +97,7 @@ export function mapQuoteLineItemsToPriceSummaryLines(
97
97
  label: label || type || 'Line',
98
98
  amount,
99
99
  type: summaryType,
100
+ quantity: qty > 0 ? qty : null,
100
101
  });
101
102
  }
102
103
  return out;