@ticketboothapp/booking 1.2.77 → 1.2.79

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.79",
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).
@@ -937,6 +936,9 @@ export function AdminChangeBookingFlow({
937
936
  } | null>(null);
938
937
  const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
939
938
  const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
939
+ /** Dedupe quote calls while user input payload is unchanged. */
940
+ const lastCompletedQuoteInputsKeyRef = useRef<string | null>(null);
941
+ const inFlightQuoteInputsKeyRef = useRef<string | null>(null);
940
942
  const selfServePricingConfirmed =
941
943
  suppressSelfServeCurrencyUi &&
942
944
  latestChangeQuote != null &&
@@ -3148,140 +3150,24 @@ export function AdminChangeBookingFlow({
3148
3150
  ),
3149
3151
  [checkoutPriceSummaryLinesForCheckout],
3150
3152
  );
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,
3153
+ const {
3154
+ editableCheckoutPriceSummaryLines,
3187
3155
  editableSummaryLineAmountInputs,
3188
3156
  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
- }, [
3157
+ editableSummaryPreSubtotalDelta,
3158
+ editableSummaryPreSubtotalDebugRows,
3159
+ onLineAmountInputChange: handleEditableLineAmountInputChange,
3160
+ onLineAmountInputBlur: handleEditableLineAmountInputBlur,
3161
+ onLineAmountReset: handleEditableLineAmountReset,
3162
+ } = useEditableSummaryLines(
3281
3163
  checkoutPriceSummaryLinesForCheckout,
3282
- editableSummaryLineAmountInputs,
3283
- getEditableSummaryLineKey,
3284
- ]);
3164
+ {
3165
+ enabled: showProviderPricingInlineEditor,
3166
+ onChange: providerPricingUi?.onLineAmountInputChange,
3167
+ onBlur: providerPricingUi?.onLineAmountInputBlur,
3168
+ onReset: providerPricingUi?.onLineAmountReset,
3169
+ },
3170
+ );
3285
3171
 
3286
3172
  // Promo discount from backend (order-level only; rates are pre-promo)
3287
3173
  const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
@@ -3813,6 +3699,40 @@ export function AdminChangeBookingFlow({
3813
3699
  !!lastName.trim();
3814
3700
 
3815
3701
  const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
3702
+ const changeQuoteInputsKey = useMemo(() => JSON.stringify({
3703
+ bookingReference: initialValues?.bookingReference?.trim() ?? '',
3704
+ lastName: lastName.trim().toLowerCase(),
3705
+ optionId: selectedAvailability?.productOptionId?.trim() || activeOptions[0]?.optionId || '',
3706
+ dateTime: selectedAvailability?.dateTime ?? '',
3707
+ availabilityId: selectedAvailability?.availabilityId ?? null,
3708
+ pickupLocationId: pickupLocationId ?? null,
3709
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
3710
+ quantities,
3711
+ addOnSelections,
3712
+ adminCustomLinesAsAdditionalAdjustments,
3713
+ feReceiptSubtotal: adminFeAuthoritativeReceipt.subtotal,
3714
+ feReceiptTax: adminFeAuthoritativeReceipt.tax,
3715
+ feReceiptTotal: adminFeAuthoritativeReceipt.total,
3716
+ feReceiptLineItems: adminFeAuthoritativeReceipt.lineItems,
3717
+ useAdminFeAuthoritativeQuote,
3718
+ }), [
3719
+ initialValues?.bookingReference,
3720
+ lastName,
3721
+ selectedAvailability?.productOptionId,
3722
+ selectedAvailability?.dateTime,
3723
+ selectedAvailability?.availabilityId,
3724
+ activeOptions,
3725
+ pickupLocationId,
3726
+ selectedReturnOption?.returnAvailabilityId,
3727
+ quantities,
3728
+ addOnSelections,
3729
+ adminCustomLinesAsAdditionalAdjustments,
3730
+ adminFeAuthoritativeReceipt.subtotal,
3731
+ adminFeAuthoritativeReceipt.tax,
3732
+ adminFeAuthoritativeReceipt.total,
3733
+ adminFeAuthoritativeReceipt.lineItems,
3734
+ useAdminFeAuthoritativeQuote,
3735
+ ]);
3816
3736
  const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
3817
3737
  const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
3818
3738
 
@@ -3871,7 +3791,11 @@ export function AdminChangeBookingFlow({
3871
3791
  * UI-only gate — does not alter ticket line items or receipt-floor rules.
3872
3792
  */
3873
3793
  const showChangeFlowManualPriceLines =
3874
- changeFlowFinalPricePending || selfServePricingConfirmed;
3794
+ hasEffectiveChangeSelection && (changeFlowFinalPricePending || selfServePricingConfirmed);
3795
+ const showAdminCustomLineEditor =
3796
+ isAdmin &&
3797
+ selectedAvailability != null &&
3798
+ totalQuantity > 0;
3875
3799
 
3876
3800
  const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3877
3801
  providerPreview: providerTotalsPreview,
@@ -4241,11 +4165,11 @@ export function AdminChangeBookingFlow({
4241
4165
  return;
4242
4166
  }
4243
4167
  onPricePreviewChange({
4244
- subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotalForCheckout,
4168
+ subtotal: originalReceipt ? adminFeAuthoritativeReceipt.subtotal : effectiveSubtotalForCheckout,
4245
4169
  tax:
4246
4170
  !isTaxIncludedInPrice
4247
4171
  ? originalReceipt
4248
- ? displayChangeFlowTax
4172
+ ? adminFeAuthoritativeReceipt.tax
4249
4173
  : effectiveTax
4250
4174
  : 0,
4251
4175
  total: originalReceipt ? displayChangeFlowProposedTotalWithEditableLines : totalPrice,
@@ -4258,8 +4182,8 @@ export function AdminChangeBookingFlow({
4258
4182
  effectiveSubtotalForCheckout,
4259
4183
  effectiveTax,
4260
4184
  displayChangeFlowProposedTotalWithEditableLines,
4261
- displayChangeFlowSubtotal,
4262
- displayChangeFlowTax,
4185
+ adminFeAuthoritativeReceipt.subtotal,
4186
+ adminFeAuthoritativeReceipt.tax,
4263
4187
  currency,
4264
4188
  isTaxIncludedInPrice,
4265
4189
  originalReceipt,
@@ -4287,6 +4211,17 @@ export function AdminChangeBookingFlow({
4287
4211
  setLatestChangeQuote(null);
4288
4212
  setChangeQuoteLoading(false);
4289
4213
  setChangeQuoteFetchError(null);
4214
+ lastCompletedQuoteInputsKeyRef.current = null;
4215
+ inFlightQuoteInputsKeyRef.current = null;
4216
+ return;
4217
+ }
4218
+
4219
+ if (
4220
+ changeQuoteInputsKey &&
4221
+ (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
4222
+ lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
4223
+ ) {
4224
+ setChangeQuoteLoading(false);
4290
4225
  return;
4291
4226
  }
4292
4227
 
@@ -4305,6 +4240,7 @@ export function AdminChangeBookingFlow({
4305
4240
  const bookingReferenceForQuote = initialValues.bookingReference.trim();
4306
4241
  const timer = window.setTimeout(() => {
4307
4242
  void (async () => {
4243
+ inFlightQuoteInputsKeyRef.current = changeQuoteInputsKey;
4308
4244
  const bookingItems = Object.entries(quantities)
4309
4245
  .filter(([, count]) => count > 0)
4310
4246
  .map(([category, count]) => ({ category, count }));
@@ -4331,11 +4267,18 @@ export function AdminChangeBookingFlow({
4331
4267
  previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
4332
4268
  },
4333
4269
  };
4270
+ const deterministicClientProposedTotal = useAdminFeAuthoritativeQuote
4271
+ ? adminFeAuthoritativeReceipt.total
4272
+ : changeFlowNewBookingTotal;
4273
+ const deterministicAmountDue = originalReceipt
4274
+ ? roundMoney(adminFeAuthoritativeReceipt.total - originalReceipt.total)
4275
+ : roundMoney(changeFlowClientEstimateDue);
4334
4276
  const quote = useAdminFeAuthoritativeQuote
4335
4277
  ? await quoteChangeBookingAdminFeReceipt({
4336
4278
  ...quoteRequestBase,
4337
4279
  feReceipt: adminFeAuthoritativeReceipt,
4338
- feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4280
+ feAmountDueMajorUnits: deterministicAmountDue,
4281
+ clientProposedTotal: deterministicClientProposedTotal,
4339
4282
  })
4340
4283
  : await quoteChangeBooking(quoteRequestBase);
4341
4284
  if (seq !== changeQuoteRequestSeq.current) return;
@@ -4355,11 +4298,16 @@ export function AdminChangeBookingFlow({
4355
4298
  tax: effectiveTax,
4356
4299
  }, currency),
4357
4300
  );
4301
+ lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
4358
4302
  } catch (e) {
4359
4303
  if (seq !== changeQuoteRequestSeq.current) return;
4360
4304
  setLatestChangeQuote(null);
4361
4305
  setChangeQuoteFetchError(e instanceof Error ? e.message : 'Failed to get price for this change');
4306
+ lastCompletedQuoteInputsKeyRef.current = null;
4362
4307
  } finally {
4308
+ if (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
4309
+ inFlightQuoteInputsKeyRef.current = null;
4310
+ }
4363
4311
  if (seq === changeQuoteRequestSeq.current) {
4364
4312
  setChangeQuoteLoading(false);
4365
4313
  }
@@ -4396,6 +4344,8 @@ export function AdminChangeBookingFlow({
4396
4344
  adminCustomLinesAsAdditionalAdjustments,
4397
4345
  adminFeAuthoritativeReceipt,
4398
4346
  useAdminFeAuthoritativeQuote,
4347
+ changeQuoteInputsKey,
4348
+ originalReceipt?.total,
4399
4349
  ]);
4400
4350
 
4401
4351
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4930,13 +4880,19 @@ export function AdminChangeBookingFlow({
4930
4880
  ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
4931
4881
  : {}),
4932
4882
  clientProposedTotal:
4933
- latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4883
+ useAdminFeAuthoritativeQuote
4884
+ ? adminFeAuthoritativeReceipt.total
4885
+ : (latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal),
4934
4886
  };
4935
4887
  const quote = useAdminFeAuthoritativeQuote
4936
4888
  ? await quoteChangeBookingAdminFeReceipt({
4937
4889
  ...quoteRequestBase,
4938
4890
  feReceipt: adminFeAuthoritativeReceipt,
4939
- feAmountDueMajorUnits: roundMoney(changeFlowClientEstimateDue),
4891
+ feAmountDueMajorUnits: roundMoney(
4892
+ originalReceipt
4893
+ ? adminFeAuthoritativeReceipt.total - originalReceipt.total
4894
+ : changeFlowClientEstimateDue
4895
+ ),
4940
4896
  })
4941
4897
  : await quoteChangeBooking(quoteRequestBase);
4942
4898
  const quoteSlice = sliceChangeQuoteForUi(
@@ -5772,7 +5728,7 @@ export function AdminChangeBookingFlow({
5772
5728
  <>
5773
5729
  <CheckoutForm
5774
5730
  priceSummaryLines={editableCheckoutPriceSummaryLines}
5775
- replacePriceSummary={selfServeCheckoutPlaceholder}
5731
+ replacePriceSummary={hasChangeSelection ? selfServeCheckoutPlaceholder : undefined}
5776
5732
  totalPrice={changeFlowAmountDue}
5777
5733
  totalSummaryLabel={
5778
5734
  changeFlowAmountDue < -0.005
@@ -5782,12 +5738,12 @@ export function AdminChangeBookingFlow({
5782
5738
  ? t('booking.totalOwedForBookingChange')
5783
5739
  : 'Total owed for booking difference'
5784
5740
  }
5785
- subtotal={displayChangeFlowSubtotal}
5741
+ subtotal={adminFeAuthoritativeReceipt.subtotal}
5786
5742
  taxAmount={
5787
5743
  !isTaxIncludedInPrice &&
5788
- displayChangeFlowTax > 0 &&
5744
+ adminFeAuthoritativeReceipt.tax > 0 &&
5789
5745
  !priceSummaryLinesIncludeTaxRow
5790
- ? displayChangeFlowTax
5746
+ ? adminFeAuthoritativeReceipt.tax
5791
5747
  : 0
5792
5748
  }
5793
5749
  taxRate={pricingConfig?.taxRate}
@@ -5813,9 +5769,11 @@ export function AdminChangeBookingFlow({
5813
5769
  </>
5814
5770
  }
5815
5771
  extraBeforeSubtotal={
5816
- showChangeFlowManualPriceLines ? (
5772
+ (showChangeFlowManualPriceLines || showAdminCustomLineEditor) ? (
5817
5773
  <>
5818
- {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5774
+ {showChangeFlowManualPriceLines &&
5775
+ showProviderPricingInlineEditor &&
5776
+ (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5819
5777
  <div className="space-y-1">
5820
5778
  {providerPricingUi?.additionalAdjustments?.map((adj) => (
5821
5779
  <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
@@ -5829,7 +5787,7 @@ export function AdminChangeBookingFlow({
5829
5787
  </button>
5830
5788
  <input
5831
5789
  type="text"
5832
- className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5790
+ className="admin-custom-receipt-input w-40 rounded border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
5833
5791
  placeholder="Line description"
5834
5792
  value={adj.label}
5835
5793
  onChange={(e) =>
@@ -5853,7 +5811,7 @@ export function AdminChangeBookingFlow({
5853
5811
  <input
5854
5812
  type="text"
5855
5813
  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"
5814
+ 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
5815
  placeholder="0.00"
5858
5816
  value={adj.amountInput}
5859
5817
  onChange={(e) =>
@@ -5873,7 +5831,7 @@ export function AdminChangeBookingFlow({
5873
5831
  + add line item
5874
5832
  </button>
5875
5833
  </div>
5876
- ) : showProviderPricingInlineEditor ? (
5834
+ ) : showChangeFlowManualPriceLines && showProviderPricingInlineEditor ? (
5877
5835
  <button
5878
5836
  type="button"
5879
5837
  className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
@@ -5882,6 +5840,7 @@ export function AdminChangeBookingFlow({
5882
5840
  + add line item
5883
5841
  </button>
5884
5842
  ) : null}
5843
+ {showAdminCustomLineEditor ? (
5885
5844
  <div className="space-y-3">
5886
5845
  <div className="flex justify-center">
5887
5846
  <button
@@ -5995,6 +5954,7 @@ export function AdminChangeBookingFlow({
5995
5954
  );
5996
5955
  })}
5997
5956
  </div>
5957
+ ) : null}
5998
5958
  </>
5999
5959
  ) : null
6000
5960
  }
@@ -6061,48 +6021,10 @@ export function AdminChangeBookingFlow({
6061
6021
  attributionConfirmed={partnerAttributionConfirmed}
6062
6022
  onAttributionConfirmedChange={setPartnerAttributionConfirmed}
6063
6023
  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
- }}
6024
+ onLineAmountInputChange={handleEditableLineAmountInputChange}
6025
+ onLineAmountInputBlur={handleEditableLineAmountInputBlur}
6026
+ onLineAmountReset={handleEditableLineAmountReset}
6102
6027
  lineLabelInputs={editableSummaryLineLabelInputs}
6103
- onLineLabelInputChange={(lineKey, value) => {
6104
- setEditableSummaryLineLabelInputs((prev) => ({ ...prev, [lineKey]: value }));
6105
- }}
6106
6028
  />
6107
6029
  </>
6108
6030
  )}
@@ -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;