Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import PercentageBadge from '@/components/common/PercentageBadge';
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
import { clampBuyQuantity } from '@/utils/buyQuantity';
import {
fetchTradeNetworkFeeEstimate,
formatTransactionFeeDisplay,
Expand Down Expand Up @@ -55,12 +56,36 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
const [amountText, setAmountText] = useState('1');
const [networkFeeEstimate, setNetworkFeeEstimate] =
useState<NetworkFeeEstimateState>({ status: 'idle', fee: null });
const [adjustmentNote, setAdjustmentNote] = useState<string | null>(null);
const amountInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
if (open) setAmountText('1');
if (open) {
setAmountText('1');
setAdjustmentNote(null);
}
}, [open]);

const handleBlur = () => {
if (side !== 'buy') return;

const trimmed = amountText.trim();
const res = clampBuyQuantity(trimmed);

if (res.adjusted) {
setAmountText(res.value.toString());
if (res.reason === 'below_min') {
setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`);
} else if (res.reason === 'above_max') {
setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`);
} else {
setAdjustmentNote(`Quantity rounded to ${res.value}.`);
}
} else {
setAdjustmentNote(null);
}
};

const parsedAmount = useMemo(() => {
const normalized = amountText.trim();
if (!normalized) return NaN;
Expand Down Expand Up @@ -176,7 +201,11 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
ref={amountInputRef}
inputMode="decimal"
value={amountText}
onChange={event => setAmountText(event.target.value)}
onChange={event => {
setAmountText(event.target.value);
setAdjustmentNote(null);
}}
onBlur={handleBlur}
disabled={isSubmitting}
className={cn(
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
Expand All @@ -189,6 +218,11 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
data-focus-order="1"
data-testid="trade-dialog-amount"
/>
{side === 'buy' && adjustmentNote && (
<div className="text-xs text-amber-400 font-medium animate-in fade-in duration-200" data-testid="buy-qty-adjustment-note">
{adjustmentNote}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
<span
aria-label={`Current wallet holdings: ${formatNumber(availableHoldings)} keys`}
Expand Down
90 changes: 90 additions & 0 deletions src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TradeDialog from '@/components/common/TradeDialog';
import { BUY_QUANTITY_BOUNDS } from '@/constants/fees';

describe('TradeDialog buy quantity clamping and notes', () => {
function renderDialog(
overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}
) {
return render(
<TradeDialog
open={true}
side="buy"
creatorName="Alice"
availableHoldings={10}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
{...overrides}
/>
);
}

it('clamps values below minimum to minimum on blur', () => {
renderDialog();
const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;

// Input below minimum
fireEvent.change(input, { target: { value: '0' } });
fireEvent.blur(input);

expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString());
expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
`Quantity adjusted to the minimum of ${BUY_QUANTITY_BOUNDS.MIN_QTY}.`
);
});

it('clamps values above maximum to maximum on blur', () => {
renderDialog();
const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;

// Input above maximum
fireEvent.change(input, { target: { value: '150' } });
fireEvent.blur(input);

expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString());
expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
`Quantity adjusted to the maximum of ${BUY_QUANTITY_BOUNDS.MAX_QTY}.`
);
});

it('rounds decimal inputs on blur', () => {
renderDialog();
const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;

// Decimal input
fireEvent.change(input, { target: { value: '5.6' } });
fireEvent.blur(input);

expect(input.value).toBe('6');
expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
'Quantity rounded to 6.'
);
});

it('does not clamp or show a note for valid quantities on blur', () => {
renderDialog();
const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;

// Valid quantity
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);

expect(input.value).toBe('10');
expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument();
});

it('clears the adjustment note on input change', () => {
renderDialog();
const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;

// Trigger adjustment note
fireEvent.change(input, { target: { value: '0' } });
fireEvent.blur(input);
expect(screen.getByTestId('buy-qty-adjustment-note')).toBeInTheDocument();

// Change input
fireEvent.change(input, { target: { value: '5' } });
expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions src/constants/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const KEY_PRICE_BOUNDS = {
MAX_PRICE: 100,
} as const;

export const BUY_QUANTITY_BOUNDS = {
MIN_QTY: 1,
MAX_QTY: 100,
} as const;

export const TRADE_FEE_ESTIMATE = {
DEFAULT_NETWORK_FEE: 0.0001,
UNIT: 'ETH',
Expand Down
75 changes: 75 additions & 0 deletions src/utils/__tests__/buyQuantity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { clampBuyQuantity } from '../buyQuantity';
import { BUY_QUANTITY_BOUNDS } from '@/constants/fees';

describe('clampBuyQuantity utility', () => {
it('keeps valid integer inputs within bounds unchanged', () => {
const result = clampBuyQuantity(5);
expect(result.value).toBe(5);
expect(result.adjusted).toBe(false);
expect(result.reason).toBe('none');
});

it('keeps border values unchanged', () => {
const minResult = clampBuyQuantity(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(minResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(minResult.adjusted).toBe(false);

const maxResult = clampBuyQuantity(BUY_QUANTITY_BOUNDS.MAX_QTY);
expect(maxResult.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY);
expect(maxResult.adjusted).toBe(false);
});

it('clamps values below minimum to minimum', () => {
const zeroResult = clampBuyQuantity(0);
expect(zeroResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(zeroResult.adjusted).toBe(true);
expect(zeroResult.reason).toBe('below_min');

const negativeResult = clampBuyQuantity(-10);
expect(negativeResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(negativeResult.adjusted).toBe(true);
expect(negativeResult.reason).toBe('below_min');
});

it('clamps values above maximum to maximum', () => {
const excessiveResult = clampBuyQuantity(105);
expect(excessiveResult.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY);
expect(excessiveResult.adjusted).toBe(true);
expect(excessiveResult.reason).toBe('above_max');
});

it('handles invalid numeric inputs (NaN, empty string) by clamping to minimum', () => {
const nanResult = clampBuyQuantity(NaN);
expect(nanResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(nanResult.adjusted).toBe(true);
expect(nanResult.reason).toBe('below_min');

const emptyResult = clampBuyQuantity('');
expect(emptyResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY);
expect(emptyResult.adjusted).toBe(true);
expect(emptyResult.reason).toBe('below_min');
});

it('rounds fractional inputs to the nearest integer', () => {
const fractionResult = clampBuyQuantity(5.4);
expect(fractionResult.value).toBe(5);
expect(fractionResult.adjusted).toBe(true);

const fractionUpResult = clampBuyQuantity(5.7);
expect(fractionUpResult.value).toBe(6);
expect(fractionUpResult.adjusted).toBe(true);
});

it('allows custom min and max bounds', () => {
const customResult = clampBuyQuantity(25, 5, 20);
expect(customResult.value).toBe(20);
expect(customResult.adjusted).toBe(true);
expect(customResult.reason).toBe('above_max');

const customMinResult = clampBuyQuantity(2, 5, 20);
expect(customMinResult.value).toBe(5);
expect(customMinResult.adjusted).toBe(true);
expect(customMinResult.reason).toBe('below_min');
});
});
64 changes: 64 additions & 0 deletions src/utils/buyQuantity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { BUY_QUANTITY_BOUNDS } from '@/constants/fees';

export interface ClampingResult {
value: number;
adjusted: boolean;
original: number;
reason: 'below_min' | 'above_max' | 'none';
}

/**
* Clamps a given buy quantity to the defined minimum and maximum bounds.
* Non-numeric inputs are treated as below the minimum.
* Values are rounded to the nearest integer as keys are discrete assets.
*
* @param input The input value to clamp.
* @param min The minimum allowed quantity (defaults to BUY_QUANTITY_BOUNDS.MIN_QTY).
* @param max The maximum allowed quantity (defaults to BUY_QUANTITY_BOUNDS.MAX_QTY).
*/
export function clampBuyQuantity(
input: number | string,
min: number = BUY_QUANTITY_BOUNDS.MIN_QTY,
max: number = BUY_QUANTITY_BOUNDS.MAX_QTY
): ClampingResult {
const parsed = typeof input === 'number' ? input : parseFloat(input);

if (isNaN(parsed) || !isFinite(parsed)) {
return {
value: min,
adjusted: true,
original: NaN,
reason: 'below_min',
};
}

// Quantities must be integers
const rounded = Math.round(parsed);

if (rounded < min) {
return {
value: min,
adjusted: true,
original: parsed,
reason: 'below_min',
};
}

if (rounded > max) {
return {
value: max,
adjusted: true,
original: parsed,
reason: 'above_max',
};
}

const adjustedByRounding = rounded !== parsed;

return {
value: rounded,
adjusted: adjustedByRounding,
original: parsed,
reason: 'none',
};
}
Loading