From 3846f5d55756ec334da93259ab9071310f634b57 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 22 Dec 2025 19:26:46 +0800 Subject: [PATCH] feat(AmountInput): migrate to native Amount type support BREAKING CHANGE: AmountInput now uses Amount objects instead of strings - value: Amount | null (was string) - onChange: (Amount | null) => void (was string => void) - balance: Amount (was string) - max: Amount (was string) Updates: - useSend hook: state.amount is now Amount | null - SendPage: integrated with new AmountInput API - Tests: updated all test files with Amount objects - Stories: updated to use Amount for demos Closes #65 --- .../transfer/amount-input.stories.tsx | 113 +++++++++------- src/components/transfer/amount-input.test.tsx | 104 +++++++++++---- src/components/transfer/amount-input.tsx | 124 +++++++++++++----- src/components/transfer/index.ts | 2 +- src/hooks/use-send.constants.ts | 2 +- src/hooks/use-send.logic.ts | 35 ++--- src/hooks/use-send.test.ts | 44 ++++--- src/hooks/use-send.ts | 24 +++- src/hooks/use-send.types.ts | 8 +- src/pages/send/index.tsx | 24 ++-- 10 files changed, 313 insertions(+), 167 deletions(-) diff --git a/src/components/transfer/amount-input.stories.tsx b/src/components/transfer/amount-input.stories.tsx index 9ea8121d5..c57548dd4 100644 --- a/src/components/transfer/amount-input.stories.tsx +++ b/src/components/transfer/amount-input.stories.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { AmountInput } from './amount-input'; +import { Amount } from '@/types/amount'; const meta: Meta = { title: 'Transfer/AmountInput', @@ -11,20 +12,22 @@ const meta: Meta = { export default meta; type Story = StoryObj; +// Helper to create Amount objects for stories +const createAmount = (value: string, decimals: number, symbol: string) => + Amount.fromFormatted(value, decimals, symbol); + export const Default: Story = { args: { label: '转账金额', - symbol: 'USDT', - balance: '1,234.56', + balance: createAmount('1234.56', 6, 'USDT'), }, }; export const WithValue: Story = { args: { label: '转账金额', - symbol: 'ETH', - value: '0.5', - balance: '2.5', + value: createAmount('0.5', 18, 'ETH'), + balance: createAmount('2.5', 18, 'ETH'), fiatValue: '900.00', }, }; @@ -32,18 +35,17 @@ export const WithValue: Story = { export const WithError: Story = { args: { label: '转账金额', - symbol: 'USDT', - value: '5000', - balance: '1000', - max: '1000', + value: createAmount('5000', 6, 'USDT'), + balance: createAmount('1000', 6, 'USDT'), + max: createAmount('1000', 6, 'USDT'), }, }; export const CustomError: Story = { args: { label: '转账金额', - symbol: 'USDT', - value: '10', + value: createAmount('10', 6, 'USDT'), + balance: createAmount('1000', 6, 'USDT'), error: '最小转账金额为 20 USDT', }, }; @@ -51,35 +53,34 @@ export const CustomError: Story = { export const Disabled: Story = { args: { label: '转账金额', - symbol: 'USDT', - value: '100', + value: createAmount('100', 6, 'USDT'), + balance: createAmount('1000', 6, 'USDT'), disabled: true, }, }; export const Controlled: Story = { render: () => { - const [value, setValue] = useState(''); - const balance = '1000'; + const [value, setValue] = useState(null); + const balance = createAmount('1000', 6, 'USDT'); const rate = 1; // USDT to USD - const fiatValue = value ? (parseFloat(value) * rate).toFixed(2) : undefined; - - const inputProps: Record = { - label: '转账金额', - symbol: 'USDT', - value, - onChange: setValue, - balance, - max: balance, - }; - if (fiatValue !== undefined) { - inputProps.fiatValue = fiatValue; - } + const fiatValue = value ? (value.toNumber() * rate).toFixed(2) : undefined; return (
- -

输入值: {value || '(空)'}

+ +

+ 输入值: {value?.toFormatted() ?? '(空)'} +

+

+ 原始值: {value?.toRawString() ?? '(空)'} +

); }, @@ -88,42 +89,54 @@ export const Controlled: Story = { export const DifferentTokens: Story = { render: () => (
- - - + + +
), }; export const TransferForm: Story = { render: () => { - const [amount, setAmount] = useState(''); - const balance = '1000'; - const numAmount = parseFloat(amount) || 0; - const isValid = numAmount > 0 && numAmount <= parseFloat(balance); - const fiatValue = amount ? (parseFloat(amount) || 0).toFixed(2) : undefined; + const [amount, setAmount] = useState(null); + const balance = createAmount('1000', 6, 'USDT'); + const isValid = amount && amount.isPositive() && amount.lte(balance); + const fiatValue = amount ? amount.toNumber().toFixed(2) : undefined; - const formInputProps: Record = { - label: '转账金额', - symbol: 'USDT', - value: amount, - onChange: setAmount, - balance, - max: balance, + const handleQuickSelect = (val: string) => { + setAmount(createAmount(val, 6, 'USDT')); }; - if (fiatValue !== undefined) { - formInputProps.fiatValue = fiatValue; - } return (

转账 USDT

- +
{['100', '500', '1000'].map((val) => (
- {fiatValue && currentValue && ( + {fiatValue && inputValue && (

≈ {fiatSymbol} {fiatValue} @@ -144,4 +202,4 @@ const AmountInput = forwardRef( ); AmountInput.displayName = 'AmountInput'; -export { AmountInput, formatInputValue }; +export { AmountInput, sanitizeInput, limitDecimals }; diff --git a/src/components/transfer/index.ts b/src/components/transfer/index.ts index 03a459c7f..29316e53b 100644 --- a/src/components/transfer/index.ts +++ b/src/components/transfer/index.ts @@ -1,2 +1,2 @@ export { AddressInput, isValidAddress } from './address-input' -export { AmountInput, formatInputValue } from './amount-input' +export { AmountInput, sanitizeInput, limitDecimals } from './amount-input' diff --git a/src/hooks/use-send.constants.ts b/src/hooks/use-send.constants.ts index 30f0753f1..87f4474cb 100644 --- a/src/hooks/use-send.constants.ts +++ b/src/hooks/use-send.constants.ts @@ -14,7 +14,7 @@ export const initialState: SendState = { step: 'input', asset: null, toAddress: '', - amount: '', + amount: null, addressError: null, amountError: null, feeAmount: null, diff --git a/src/hooks/use-send.logic.ts b/src/hooks/use-send.logic.ts index f4c4f98d8..ddbdfe6da 100644 --- a/src/hooks/use-send.logic.ts +++ b/src/hooks/use-send.logic.ts @@ -15,61 +15,54 @@ export function validateAddressInput(address: string, isBioforestChain: boolean) return null } -export function validateAmountInput(amount: string, asset: AssetInfo | null): string | null { - if (!amount.trim()) return '请输入金额' +export function validateAmountInput(amount: Amount | null, asset: AssetInfo | null): string | null { + if (!amount) return '请输入金额' if (!asset) return null - const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) - if (!inputAmount || !inputAmount.isPositive()) return '请输入有效金额' - - if (inputAmount.gt(asset.amount)) return '余额不足' + if (!amount.isPositive()) return '请输入有效金额' + if (amount.gt(asset.amount)) return '余额不足' return null } export function canProceedToConfirm(options: { toAddress: string - amount: string + amount: Amount | null asset: AssetInfo | null isBioforestChain: boolean }): boolean { const { toAddress, amount, asset, isBioforestChain } = options - if (!asset) return false - - const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) - if (!inputAmount) return false + if (!asset || !amount) return false return ( toAddress.trim() !== '' && - amount.trim() !== '' && isValidRecipientAddress(toAddress, isBioforestChain) && - inputAmount.isPositive() && - inputAmount.lte(asset.amount) + amount.isPositive() && + amount.lte(asset.amount) ) } export type FeeAdjustResult = - | { status: 'ok'; adjustedAmount?: string } + | { status: 'ok'; adjustedAmount?: Amount } | { status: 'error'; message: string } export function adjustAmountForFee( - amount: string, + amount: Amount | null, asset: AssetInfo, fee: Amount ): FeeAdjustResult { - const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) - if (!inputAmount) return { status: 'error', message: '请输入有效金额' } + if (!amount) return { status: 'error', message: '请输入有效金额' } const balance = asset.amount - if (inputAmount.add(fee).lte(balance)) return { status: 'ok' } - if (!inputAmount.eq(balance)) return { status: 'error', message: '余额不足' } + if (amount.add(fee).lte(balance)) return { status: 'ok' } + if (!amount.eq(balance)) return { status: 'error', message: '余额不足' } const maxSendable = balance.sub(fee) if (!maxSendable.isPositive()) return { status: 'error', message: '余额不足' } return { status: 'ok', - adjustedAmount: maxSendable.toFormatted(), + adjustedAmount: maxSendable, } } diff --git a/src/hooks/use-send.test.ts b/src/hooks/use-send.test.ts index 0ef75bfb0..669b4868a 100644 --- a/src/hooks/use-send.test.ts +++ b/src/hooks/use-send.test.ts @@ -29,7 +29,7 @@ describe('useSend', () => { it('starts with empty values', () => { const { result } = renderHook(() => useSend()) expect(result.current.state.toAddress).toBe('') - expect(result.current.state.amount).toBe('') + expect(result.current.state.amount).toBeNull() expect(result.current.state.asset).toBeNull() }) @@ -66,11 +66,12 @@ describe('useSend', () => { describe('setAmount', () => { it('updates amount', () => { - const { result } = renderHook(() => useSend()) + const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('1.5', 18, 'ETH') act(() => { - result.current.setAmount('1.5') + result.current.setAmount(amount) }) - expect(result.current.state.amount).toBe('1.5') + expect(result.current.state.amount?.toFormatted()).toBe('1.5') }) it('clears amount error on change', () => { @@ -83,8 +84,9 @@ describe('useSend', () => { expect(result.current.state.amountError).not.toBeNull() // Now set amount + const amount = Amount.fromFormatted('0.5', 18, 'ETH') act(() => { - result.current.setAmount('0.5') + result.current.setAmount(amount) }) expect(result.current.state.amountError).toBeNull() }) @@ -115,8 +117,9 @@ describe('useSend', () => { describe('canProceed', () => { it('returns false when address is empty', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('1', 18, 'ETH') act(() => { - result.current.setAmount('1') + result.current.setAmount(amount) }) expect(result.current.canProceed).toBe(false) }) @@ -131,18 +134,20 @@ describe('useSend', () => { it('returns false when asset is null', () => { const { result } = renderHook(() => useSend()) + const amount = Amount.fromFormatted('1', 18, 'ETH') act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('1') + result.current.setAmount(amount) }) expect(result.current.canProceed).toBe(false) }) it('returns true when all fields are valid', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('0.5', 18, 'ETH') act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('0.5') + result.current.setAmount(amount) }) expect(result.current.canProceed).toBe(true) }) @@ -151,8 +156,9 @@ describe('useSend', () => { describe('goToConfirm', () => { it('returns false and sets error for empty address', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('1', 18, 'ETH') act(() => { - result.current.setAmount('1') + result.current.setAmount(amount) }) let success: boolean @@ -167,9 +173,10 @@ describe('useSend', () => { it('returns false and sets error for invalid address', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('1', 18, 'ETH') act(() => { result.current.setToAddress('invalid') - result.current.setAmount('1') + result.current.setAmount(amount) }) let success: boolean @@ -198,9 +205,10 @@ describe('useSend', () => { it('returns false for insufficient balance', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('10', 18, 'ETH') // More than 1 ETH balance act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('10') // More than 1 ETH balance + result.current.setAmount(amount) }) let success: boolean @@ -214,9 +222,10 @@ describe('useSend', () => { it('returns true and goes to confirm for valid input', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('0.5', 18, 'ETH') act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('0.5') + result.current.setAmount(amount) }) let success: boolean @@ -232,10 +241,11 @@ describe('useSend', () => { describe('goBack', () => { it('returns to input step', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('0.5', 18, 'ETH') // Go to confirm first act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('0.5') + result.current.setAmount(amount) }) act(() => { result.current.goToConfirm() @@ -254,9 +264,10 @@ describe('useSend', () => { vi.useRealTimers() // Need real timers for async const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('0.5', 18, 'ETH') act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('0.5') + result.current.setAmount(amount) result.current.goToConfirm() }) @@ -281,9 +292,10 @@ describe('useSend', () => { describe('reset', () => { it('resets to initial state', () => { const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + const amount = Amount.fromFormatted('0.5', 18, 'ETH') act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') - result.current.setAmount('0.5') + result.current.setAmount(amount) result.current.goToConfirm() }) @@ -293,7 +305,7 @@ describe('useSend', () => { expect(result.current.state.step).toBe('input') expect(result.current.state.toAddress).toBe('') - expect(result.current.state.amount).toBe('') + expect(result.current.state.amount).toBeNull() expect(result.current.state.asset).toEqual(mockAsset) // Keeps initial asset }) }) diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index 6b8873acd..20e8c8828 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -26,7 +26,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { }, [isBioforestChain]) // Validate amount - const validateAmount = useCallback((amount: string, asset: AssetInfo | null): string | null => { + const validateAmount = useCallback((amount: Amount | null, asset: AssetInfo | null): string | null => { return validateAmountInput(amount, asset) }, []) @@ -40,7 +40,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { }, []) // Set amount - const setAmount = useCallback((amount: string) => { + const setAmount = useCallback((amount: Amount | null) => { setState((prev) => ({ ...prev, amount, @@ -138,7 +138,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { return false } - if (state.asset && state.feeAmount) { + if (state.asset && state.feeAmount && state.amount) { const adjustResult = adjustAmountForFee(state.amount, state.asset, state.feeAmount) if (adjustResult.status === 'error') { setState((prev) => ({ @@ -147,7 +147,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { })) return false } - if (adjustResult.adjustedAmount !== undefined) { + if (adjustResult.adjustedAmount) { setState((prev) => ({ ...prev, amount: adjustResult.adjustedAmount ?? prev.amount, @@ -221,8 +221,18 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { errorMessage: null, })) - // Convert formatted amount string to Amount object - const sendAmount = Amount.fromFormatted(state.amount, state.asset.decimals, state.asset.assetType) + // Amount should never be null here (validated above) + if (!state.amount) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: '无效的金额', + })) + return { status: 'error' as const } + } const result = await submitBioforestTransfer({ chainConfig, @@ -230,7 +240,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { password, fromAddress, toAddress: state.toAddress, - amount: sendAmount, + amount: state.amount, }) if (result.status === 'password') { diff --git a/src/hooks/use-send.types.ts b/src/hooks/use-send.types.ts index 6d2eda642..a9abc6d30 100644 --- a/src/hooks/use-send.types.ts +++ b/src/hooks/use-send.types.ts @@ -16,8 +16,8 @@ export interface SendState { asset: AssetInfo | null /** Recipient address */ toAddress: string - /** Amount to send (user-friendly format) */ - amount: string + /** Amount to send (as Amount object, null if empty/invalid) */ + amount: Amount | null /** Address validation error */ addressError: string | null /** Amount validation error */ @@ -56,8 +56,8 @@ export interface UseSendReturn { state: SendState /** Set recipient address */ setToAddress: (address: string) => void - /** Set amount */ - setAmount: (amount: string) => void + /** Set amount (Amount object or null for empty/invalid) */ + setAmount: (amount: Amount | null) => void /** Set asset */ setAsset: (asset: AssetInfo) => void /** Validate and go to confirm */ diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 078f502fe..abe907c83 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -92,13 +92,16 @@ export function SendPage() { if (initialAddress && !state.toAddress) { setToAddress(initialAddress); } - if (initialAmount && !state.amount) { - setAmount(initialAmount); + if (initialAmount && !state.amount && state.asset) { + const parsedAmount = Amount.tryFromFormatted(initialAmount, state.asset.decimals, state.asset.assetType); + if (parsedAmount) { + setAmount(parsedAmount); + } } - }, [initialAddress, initialAmount, state.toAddress, state.amount, setToAddress, setAmount]); + }, [initialAddress, initialAmount, state.toAddress, state.amount, state.asset, setToAddress, setAmount]); - // Derive formatted values - const balance = state.asset ? state.asset.amount.toFormatted() : '0'; + // Derive formatted values for display + const balance = state.asset?.amount ?? null; const symbol = state.asset?.assetType ?? 'TOKEN'; const handleScan = async () => { @@ -185,7 +188,7 @@ export function SendPage() { {/* Network warning */} @@ -248,7 +250,7 @@ export function SendPage() { open={state.step === 'confirm'} onClose={goBack} onConfirm={handleConfirm} - amount={state.amount} + amount={state.amount?.toFormatted() ?? '0'} symbol={symbol} toAddress={state.toAddress} feeAmount={state.feeAmount?.toFormatted() ?? '0'}