From e82db587425c4165ed49411080ca8d14310995f4 Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 14:43:41 +0100 Subject: [PATCH 01/13] step 1 & 2 --- src/hooks/useTokenInput.ts | 158 +++++++++++++++++-------------------- src/utils/token.ts | 38 +++++++++ 2 files changed, 109 insertions(+), 87 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index 75d935550..b61885146 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -1,8 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { BigNumber, utils } from "ethers"; -import { convertTokenToUSD, convertUSDToToken } from "utils"; +import { + convertTokenToUSD, + convertUSDToToken, + formatAmountForDisplay, + parseInputValue, + isValidNumberInput, +} from "utils"; import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; -import { formatUnitsWithMaxFractions } from "utils"; export type UnitType = "usd" | "token"; @@ -37,15 +42,35 @@ export function useTokenInput({ setUnit: externalSetUnit, }: UseTokenInputProps): UseTokenInputReturn { const [amountString, setAmountString] = useState(""); + const [localInputValue, setLocalInputValue] = useState(""); const [internalUnit, setInternalUnit] = useState("token"); const [convertedAmount, setConvertedAmount] = useState(); const [justTyped, setJustTyped] = useState(false); - // Use external unit if provided, otherwise use internal state const unit = externalUnit ?? internalUnit; const setUnit = externalSetUnit ?? setInternalUnit; - // Handle user input changes - propagate to parent + const displayValue = useMemo(() => { + if (shouldUpdate && isUpdateLoading) { + return ""; + } + if (shouldUpdate && expectedAmount && token) { + return formatAmountForDisplay(expectedAmount, token, unit); + } + return localInputValue; + }, [ + shouldUpdate, + isUpdateLoading, + expectedAmount, + token, + unit, + localInputValue, + ]); + + useEffect(() => { + setAmountString(displayValue); + }, [displayValue]); + useEffect(() => { if (!justTyped) { return; @@ -56,150 +81,109 @@ export function useTokenInput({ setAmount(null); return; } - // If the input is empty or effectively zero, set amount to null - if (!amountString || !Number(amountString)) { - setAmount(null); - return; - } - if (unit === "token") { - const parsed = utils.parseUnits(amountString, token.decimals); - // If parsed amount is zero or negative, set to null - if (parsed.lte(0)) { - setAmount(null); - return; - } - setAmount(parsed); - } else { - const tokenValue = convertUSDToToken(amountString, token); - // If converted value is zero or negative, set to null - if (tokenValue.lte(0)) { - setAmount(null); - return; - } - setAmount(tokenValue); - } + const parsed = parseInputValue(localInputValue, token, unit); + setAmount(parsed); } catch (e) { setAmount(null); } - }, [amountString, justTyped, token, unit, setAmount]); + }, [localInputValue, justTyped, token, unit, setAmount]); - // Reset amount when token changes useEffect(() => { if (token) { - setAmountString(""); + setLocalInputValue(""); setConvertedAmount(undefined); setAmount(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [token?.chainId, token?.symbol]); - // Handle quote updates - only update the field that should receive the quote useEffect(() => { if (shouldUpdate && isUpdateLoading) { setAmountString(""); } if (shouldUpdate && token) { - // Clear the field when there's no expected amount and not loading if (!expectedAmount && !isUpdateLoading) { setAmountString(""); } else { if (expectedAmount) { - if (unit === "token") { - // Display as token amount - setAmountString( - formatUnitsWithMaxFractions(expectedAmount, token.decimals) - ); - } else { - // Display as USD amount - convert token to USD - const tokenAmountFormatted = formatUnitsWithMaxFractions( - expectedAmount, - token.decimals - ); - const usdValue = convertTokenToUSD(tokenAmountFormatted, token); - // convertTokenToUSD returns in 18 decimal precision - setAmountString(utils.formatUnits(usdValue, 18)); - } + setAmountString(formatAmountForDisplay(expectedAmount, token, unit)); } } } }, [expectedAmount, isUpdateLoading, shouldUpdate, token, unit]); - // Set converted value for display useEffect(() => { - if (!token || !amountString) { + if (!token || !displayValue) { setConvertedAmount(undefined); return; } try { if (unit === "token") { - // User typed token amount - convert to USD for display - const usdValue = convertTokenToUSD(amountString, token); + const usdValue = convertTokenToUSD(displayValue, token); setConvertedAmount(usdValue); } else { - // User typed USD amount - convert to token for display - const tokenValue = convertUSDToToken(amountString, token); + const tokenValue = convertUSDToToken(displayValue, token); setConvertedAmount(tokenValue); } } catch (e) { - // getting an underflow error here setConvertedAmount(undefined); } - }, [token, amountString, unit]); + }, [token, displayValue, unit]); - // Toggle between token and USD units const toggleUnit = useCallback(() => { if (unit === "token") { - // Convert token amount to USD string for display - if (amountString && token && convertedAmount) { + if (localInputValue && token && convertedAmount) { try { - // convertedAmount is USD value in 18 decimals const a = utils.formatUnits(convertedAmount, 18); - setAmountString(a); + setLocalInputValue(a); } catch (e) { - setAmountString("0"); + setLocalInputValue("0"); } } setUnit("usd"); } else { - // Convert USD amount to token string for display - if (amountString && token && convertedAmount) { + if (localInputValue && token && convertedAmount) { try { - // convertedAmount is token value in token's native decimals const a = utils.formatUnits(convertedAmount, token.decimals); - setAmountString(a); + setLocalInputValue(a); } catch (e) { - setAmountString("0"); + setLocalInputValue("0"); } } setUnit("token"); } - }, [unit, amountString, token, convertedAmount, setUnit]); + }, [unit, localInputValue, token, convertedAmount, setUnit]); - // Handle input field changes - const handleInputChange = useCallback((value: string) => { - if (value === "" || /^\d*\.?\d*$/.test(value)) { + const handleInputChange = useCallback( + (value: string) => { + if (!isValidNumberInput(value)) { + return; + } + + setLocalInputValue(value); setJustTyped(true); - setAmountString(value); - } - }, []); - // Handle balance selector click + if (!token) { + setAmount(null); + return; + } + + try { + const parsed = parseInputValue(value, token, unit); + setAmount(parsed); + } catch (e) { + setAmount(null); + } + }, + [token, unit, setAmount] + ); + const handleBalanceClick = useCallback( - (amount: BigNumber, decimals: number) => { + (amount: BigNumber, _decimals: number) => { setAmount(amount); - if (unit === "usd" && token) { - // Convert token amount to USD for display - const tokenAmountFormatted = formatUnitsWithMaxFractions( - amount, - decimals - ); - const usdValue = convertTokenToUSD(tokenAmountFormatted, token); - // convertTokenToUSD returns in 18 decimal precision - setAmountString(utils.formatUnits(usdValue, 18)); - } else { - // Display as token amount - setAmountString(formatUnitsWithMaxFractions(amount, decimals)); + if (token) { + setLocalInputValue(formatAmountForDisplay(amount, token, unit)); } }, [setAmount, unit, token] diff --git a/src/utils/token.ts b/src/utils/token.ts index e9cc77872..0f3dc9599 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -8,11 +8,13 @@ import { getChainInfo, parseUnits, } from "utils"; +import { formatUnitsWithMaxFractions } from "utils/format"; import { ERC20__factory } from "utils/typechain"; import { SwapToken } from "utils/serverless-api/types"; import { TokenInfo } from "constants/tokens"; import { chainsWithUsdt0Enabled, getToken, tokenTable } from "utils/constants"; import usdt0Logo from "assets/token-logos/usdt0.svg"; +import { UnitType } from "hooks/useTokenInput"; export async function getNativeBalance( chainId: ChainId, @@ -231,3 +233,39 @@ export function getIntermediaryTokenInfo(tokenInfo: { }): { symbol: string; chainId: number } | undefined { return INTERMEDIARY_TOKEN_MAPPING?.[tokenInfo.chainId]?.[tokenInfo.symbol]; } + +export function formatAmountForDisplay( + amount: BigNumber, + token: LifiToken, + unit: UnitType +): string { + if (unit === "token") { + return formatUnitsWithMaxFractions(amount, token.decimals); + } else { + const tokenFormatted = formatUnitsWithMaxFractions(amount, token.decimals); + const usdValue = convertTokenToUSD(tokenFormatted, token); + return ethers.utils.formatUnits(usdValue, 18); + } +} + +export function parseInputValue( + input: string, + token: LifiToken, + unit: UnitType +): BigNumber | null { + if (!input || !Number(input)) { + return null; + } + + if (unit === "token") { + const parsed = ethers.utils.parseUnits(input, token.decimals); + return parsed.lte(0) ? null : parsed; + } else { + const tokenValue = convertUSDToToken(input, token); + return tokenValue.lte(0) ? null : tokenValue; + } +} + +export function isValidNumberInput(value: string): boolean { + return value === "" || /^\d*\.?\d*$/.test(value); +} From 0ae43637d1300b8d8442b1f78c947512c4d72ba4 Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 14:46:53 +0100 Subject: [PATCH 02/13] step 3 & 4 --- src/hooks/useTokenInput.ts | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index b61885146..55aa20fab 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -45,7 +45,6 @@ export function useTokenInput({ const [localInputValue, setLocalInputValue] = useState(""); const [internalUnit, setInternalUnit] = useState("token"); const [convertedAmount, setConvertedAmount] = useState(); - const [justTyped, setJustTyped] = useState(false); const unit = externalUnit ?? internalUnit; const setUnit = externalSetUnit ?? setInternalUnit; @@ -71,23 +70,6 @@ export function useTokenInput({ setAmountString(displayValue); }, [displayValue]); - useEffect(() => { - if (!justTyped) { - return; - } - setJustTyped(false); - try { - if (!token) { - setAmount(null); - return; - } - const parsed = parseInputValue(localInputValue, token, unit); - setAmount(parsed); - } catch (e) { - setAmount(null); - } - }, [localInputValue, justTyped, token, unit, setAmount]); - useEffect(() => { if (token) { setLocalInputValue(""); @@ -97,22 +79,6 @@ export function useTokenInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [token?.chainId, token?.symbol]); - useEffect(() => { - if (shouldUpdate && isUpdateLoading) { - setAmountString(""); - } - - if (shouldUpdate && token) { - if (!expectedAmount && !isUpdateLoading) { - setAmountString(""); - } else { - if (expectedAmount) { - setAmountString(formatAmountForDisplay(expectedAmount, token, unit)); - } - } - } - }, [expectedAmount, isUpdateLoading, shouldUpdate, token, unit]); - useEffect(() => { if (!token || !displayValue) { setConvertedAmount(undefined); @@ -162,7 +128,6 @@ export function useTokenInput({ } setLocalInputValue(value); - setJustTyped(true); if (!token) { setAmount(null); From 37651684e1341191006a38a604c7982be46e4bd4 Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 15:20:49 +0100 Subject: [PATCH 03/13] remove set amount --- src/hooks/useTokenInput.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index 55aa20fab..69d88f295 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -4,8 +4,8 @@ import { convertTokenToUSD, convertUSDToToken, formatAmountForDisplay, - parseInputValue, isValidNumberInput, + parseInputValue, } from "utils"; import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; @@ -24,7 +24,6 @@ type UseTokenInputProps = { type UseTokenInputReturn = { amountString: string; - setAmountString: (value: string) => void; unit: UnitType; convertedAmount: BigNumber | undefined; toggleUnit: () => void; @@ -41,7 +40,6 @@ export function useTokenInput({ unit: externalUnit, setUnit: externalSetUnit, }: UseTokenInputProps): UseTokenInputReturn { - const [amountString, setAmountString] = useState(""); const [localInputValue, setLocalInputValue] = useState(""); const [internalUnit, setInternalUnit] = useState("token"); const [convertedAmount, setConvertedAmount] = useState(); @@ -66,10 +64,6 @@ export function useTokenInput({ localInputValue, ]); - useEffect(() => { - setAmountString(displayValue); - }, [displayValue]); - useEffect(() => { if (token) { setLocalInputValue(""); @@ -155,8 +149,7 @@ export function useTokenInput({ ); return { - amountString, - setAmountString, + amountString: displayValue, unit, convertedAmount, toggleUnit, From 70b9e8496fb4f272571319c2f52f38cb9748e6bf Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 16:04:54 +0100 Subject: [PATCH 04/13] step 1 --- .../useQuoteRequest/QuoteRequestContext.tsx | 29 +++++++++++------ .../hooks/useQuoteRequest/initialQuote.ts | 6 ++-- .../useQuoteRequest/quoteRequestAction.ts | 16 +++++++--- .../useQuoteRequest/quoteRequestReducer.ts | 32 +++++++++++++------ 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx index 7ee3604e7..7833d261c 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx @@ -9,8 +9,12 @@ interface QuoteRequestContextValue { quoteRequest: QuoteRequest; setOriginToken: (token: EnrichedToken | null) => void; setDestinationToken: (token: EnrichedToken | null) => void; - setOriginAmount: (amount: BigNumber | null) => void; - setDestinationAmount: (amount: BigNumber | null) => void; + setUserInput: ( + field: "origin" | "destination", + value: string, + amount: BigNumber | null + ) => void; + setQuoteOutput: (amount: BigNumber | null) => void; setCustomDestinationAccount: (account: QuoteAccount) => void; resetCustomDestinationAccount: () => void; quickSwap: () => void; @@ -40,12 +44,19 @@ export const QuoteRequestProvider = ({ dispatch({ type: "SET_DESTINATION_TOKEN", payload: token }); }, []); - const setOriginAmount = useCallback((amount: BigNumber | null) => { - dispatch({ type: "SET_ORIGIN_AMOUNT", payload: amount }); - }, []); + const setUserInput = useCallback( + ( + field: "origin" | "destination", + value: string, + amount: BigNumber | null + ) => { + dispatch({ type: "SET_USER_INPUT", payload: { field, value, amount } }); + }, + [] + ); - const setDestinationAmount = useCallback((amount: BigNumber | null) => { - dispatch({ type: "SET_DESTINATION_AMOUNT", payload: amount }); + const setQuoteOutput = useCallback((amount: BigNumber | null) => { + dispatch({ type: "SET_QUOTE_OUTPUT", payload: amount }); }, []); const setCustomDestinationAccount = useCallback((account: QuoteAccount) => { @@ -66,8 +77,8 @@ export const QuoteRequestProvider = ({ quoteRequest, setOriginToken, setDestinationToken, - setOriginAmount, - setDestinationAmount, + setUserInput, + setQuoteOutput, setCustomDestinationAccount, resetCustomDestinationAccount, quickSwap, diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts index 78bd4c74d..f3ae9d7fb 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts @@ -1,9 +1,11 @@ import { QuoteRequest } from "./quoteRequestAction"; export const initialQuote: QuoteRequest = { - tradeType: "exactInput", originToken: null, destinationToken: null, customDestinationAccount: null, - amount: null, + userInputField: "origin", + userInputValue: "", + userInputAmount: null, + quoteOutputAmount: null, }; diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts index 4a52929a0..d4999c284 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts @@ -14,11 +14,15 @@ export type QuoteRequestAction = payload: EnrichedToken | null; } | { - type: "SET_ORIGIN_AMOUNT"; - payload: BigNumber | null; + type: "SET_USER_INPUT"; + payload: { + field: "origin" | "destination"; + value: string; + amount: BigNumber | null; + }; } | { - type: "SET_DESTINATION_AMOUNT"; + type: "SET_QUOTE_OUTPUT"; payload: BigNumber | null; } | { type: "SET_CUSTOM_DESTINATION_ACCOUNT"; payload: QuoteAccount } @@ -26,9 +30,11 @@ export type QuoteRequestAction = | { type: "QUICK_SWAP"; payload: undefined }; export interface QuoteRequest { - tradeType: "minOutput" | "exactInput"; originToken: EnrichedToken | null; destinationToken: EnrichedToken | null; customDestinationAccount: QuoteAccount | null; - amount: BigNumber | null; + userInputField: "origin" | "destination"; + userInputValue: string; + userInputAmount: BigNumber | null; + quoteOutputAmount: BigNumber | null; } diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts index c8783407f..4a7419f73 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts @@ -6,20 +6,32 @@ export const quoteRequestReducer = ( ): QuoteRequest => { switch (action.type) { case "SET_ORIGIN_TOKEN": - return { ...prevState, originToken: action.payload }; + return { + ...prevState, + originToken: action.payload, + userInputValue: "", + userInputAmount: null, + quoteOutputAmount: null, + }; case "SET_DESTINATION_TOKEN": - return { ...prevState, destinationToken: action.payload }; - case "SET_DESTINATION_AMOUNT": return { ...prevState, - amount: action.payload, - tradeType: "minOutput", + destinationToken: action.payload, + userInputValue: "", + userInputAmount: null, + quoteOutputAmount: null, + }; + case "SET_USER_INPUT": + return { + ...prevState, + userInputField: action.payload.field, + userInputValue: action.payload.value, + userInputAmount: action.payload.amount, }; - case "SET_ORIGIN_AMOUNT": + case "SET_QUOTE_OUTPUT": return { ...prevState, - amount: action.payload, - tradeType: "exactInput", + quoteOutputAmount: action.payload, }; case "SET_CUSTOM_DESTINATION_ACCOUNT": return { ...prevState, customDestinationAccount: action.payload }; @@ -30,8 +42,8 @@ export const quoteRequestReducer = ( ...prevState, originToken: prevState.destinationToken, destinationToken: prevState.originToken, - tradeType: - prevState.tradeType === "exactInput" ? "minOutput" : "exactInput", + userInputField: + prevState.userInputField === "origin" ? "destination" : "origin", }; default: return prevState; From 9b647ed5061d7bbced4d577fba3278bcac91c296 Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 16:22:56 +0100 Subject: [PATCH 05/13] after refactor --- src/hooks/useTokenInput.ts | 70 ++++++++----------- .../ConfirmationButton.stories.tsx | 6 +- .../Confirmation/ConfirmationButton.tsx | 4 +- .../TokenInput/DestinationTokenDisplay.tsx | 29 ++++---- .../TokenInput/OriginTokenInput.tsx | 22 ++++-- .../SwapAndBridge/hooks/useButtonState.ts | 6 +- .../quoteRequestReducer.test.ts | 47 +++++++++---- src/views/SwapAndBridge/hooks/useSwapQuote.ts | 10 +-- src/views/SwapAndBridge/utils/balance.ts | 11 ++- 9 files changed, 114 insertions(+), 91 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index 69d88f295..4aa50d50a 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -13,11 +13,11 @@ export type UnitType = "usd" | "token"; type UseTokenInputProps = { token: EnrichedToken | null; - setAmount: (amount: BigNumber | null) => void; - expectedAmount: BigNumber | undefined; - shouldUpdate: boolean; + inputValue: string; + setInputValue: (value: string, amount: BigNumber | null) => void; + isUserInput: boolean; + quoteOutputAmount: BigNumber | null | undefined; isUpdateLoading: boolean; - // Optional: Allow unit state to be controlled from parent unit?: UnitType; setUnit?: (unit: UnitType) => void; }; @@ -33,14 +33,14 @@ type UseTokenInputReturn = { export function useTokenInput({ token, - setAmount, - expectedAmount, - shouldUpdate, + inputValue, + setInputValue, + isUserInput, + quoteOutputAmount, isUpdateLoading, unit: externalUnit, setUnit: externalSetUnit, }: UseTokenInputProps): UseTokenInputReturn { - const [localInputValue, setLocalInputValue] = useState(""); const [internalUnit, setInternalUnit] = useState("token"); const [convertedAmount, setConvertedAmount] = useState(); @@ -48,31 +48,22 @@ export function useTokenInput({ const setUnit = externalSetUnit ?? setInternalUnit; const displayValue = useMemo(() => { - if (shouldUpdate && isUpdateLoading) { + if (!isUserInput && isUpdateLoading) { return ""; } - if (shouldUpdate && expectedAmount && token) { - return formatAmountForDisplay(expectedAmount, token, unit); + if (!isUserInput && quoteOutputAmount && token) { + return formatAmountForDisplay(quoteOutputAmount, token, unit); } - return localInputValue; + return inputValue; }, [ - shouldUpdate, + isUserInput, isUpdateLoading, - expectedAmount, + quoteOutputAmount, token, unit, - localInputValue, + inputValue, ]); - useEffect(() => { - if (token) { - setLocalInputValue(""); - setConvertedAmount(undefined); - setAmount(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token?.chainId, token?.symbol]); - useEffect(() => { if (!token || !displayValue) { setConvertedAmount(undefined); @@ -93,27 +84,29 @@ export function useTokenInput({ const toggleUnit = useCallback(() => { if (unit === "token") { - if (localInputValue && token && convertedAmount) { + if (inputValue && token && convertedAmount) { try { const a = utils.formatUnits(convertedAmount, 18); - setLocalInputValue(a); + const parsed = parseInputValue(a, token, "usd"); + setInputValue(a, parsed); } catch (e) { - setLocalInputValue("0"); + setInputValue("0", null); } } setUnit("usd"); } else { - if (localInputValue && token && convertedAmount) { + if (inputValue && token && convertedAmount) { try { const a = utils.formatUnits(convertedAmount, token.decimals); - setLocalInputValue(a); + const parsed = parseInputValue(a, token, "token"); + setInputValue(a, parsed); } catch (e) { - setLocalInputValue("0"); + setInputValue("0", null); } } setUnit("token"); } - }, [unit, localInputValue, token, convertedAmount, setUnit]); + }, [unit, inputValue, token, convertedAmount, setUnit, setInputValue]); const handleInputChange = useCallback( (value: string) => { @@ -121,31 +114,28 @@ export function useTokenInput({ return; } - setLocalInputValue(value); - if (!token) { - setAmount(null); + setInputValue(value, null); return; } try { const parsed = parseInputValue(value, token, unit); - setAmount(parsed); + setInputValue(value, parsed); } catch (e) { - setAmount(null); + setInputValue(value, null); } }, - [token, unit, setAmount] + [token, unit, setInputValue] ); const handleBalanceClick = useCallback( (amount: BigNumber, _decimals: number) => { - setAmount(amount); if (token) { - setLocalInputValue(formatAmountForDisplay(amount, token, unit)); + setInputValue(formatAmountForDisplay(amount, token, unit), amount); } }, - [setAmount, unit, token] + [unit, token, setInputValue] ); return { diff --git a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx index de790b67a..5a228d381 100644 --- a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx +++ b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx @@ -185,11 +185,13 @@ const mockSwapQuote: SwapApprovalApiCallReturnType = { }; const mockQuoteRequest: QuoteRequest = { - tradeType: "exactInput", + userInputField: "origin", + userInputValue: "100", + userInputAmount: BigNumber.from("100000000"), + quoteOutputAmount: BigNumber.from("99500000"), originToken: mockInputToken, destinationToken: mockOutputToken, customDestinationAccount: null, - amount: BigNumber.from("100000000"), }; const createQuoteWithProvider = ( diff --git a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.tsx index 40bbc71c4..eab89b7a1 100644 --- a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.tsx @@ -64,8 +64,8 @@ export const ConfirmationButton: React.FC = ({ const onConfirm = useOnConfirm(quoteRequest, approvalAction); const validation = useValidateSwapAndBridge( - quoteRequest.amount, - quoteRequest.tradeType === "exactInput", + quoteRequest.userInputAmount, + quoteRequest.userInputField === "origin", quoteRequest.originToken, quoteRequest.destinationToken, !!depositor, diff --git a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx index fad10b62f..7a0e800d5 100644 --- a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; import { formatUSD } from "utils"; @@ -31,17 +32,20 @@ export const DestinationTokenDisplay = ({ unit, setUnit, }: DestinationTokenDisplayProps) => { - const { - quoteRequest, - setDestinationAmount, - setOriginToken, - setDestinationToken, - } = useQuoteRequestContext(); - - const shouldUpdate = quoteRequest.tradeType === "exactInput"; + const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = + useQuoteRequestContext(); const { destinationToken, originToken } = quoteRequest; + const isUserInput = quoteRequest.userInputField === "destination"; + + const handleSetInputValue = useCallback( + (value: string, amount: BigNumber | null) => { + setUserInput("destination", value, amount); + }, + [setUserInput] + ); + const { amountString, convertedAmount, @@ -50,9 +54,10 @@ export const DestinationTokenDisplay = ({ handleBalanceClick, } = useTokenInput({ token: destinationToken, - setAmount: setDestinationAmount, - expectedAmount: expectedOutputAmount, - shouldUpdate, + inputValue: quoteRequest.userInputValue, + setInputValue: handleSetInputValue, + isUserInput, + quoteOutputAmount: quoteRequest.quoteOutputAmount, isUpdateLoading, unit, setUnit, @@ -60,7 +65,7 @@ export const DestinationTokenDisplay = ({ const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; - return Boolean(shouldUpdate && isUpdateLoading); + return Boolean(!isUserInput && isUpdateLoading); })(); const formattedConvertedAmount = (() => { diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index ba91c228e..a44412798 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; import { formatUSD } from "utils"; @@ -33,14 +33,21 @@ export const OriginTokenInput = ({ unit, setUnit, }: OriginTokenInputProps) => { - const { quoteRequest, setOriginAmount, setOriginToken, setDestinationToken } = + const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = useQuoteRequestContext(); const amountInputRef = useRef(null); const hasAutoFocusedRef = useRef(false); const { originToken, destinationToken } = quoteRequest; - const shouldUpdate = quoteRequest.tradeType === "minOutput"; + const isUserInput = quoteRequest.userInputField === "origin"; + + const handleSetInputValue = useCallback( + (value: string, amount: BigNumber | null) => { + setUserInput("origin", value, amount); + }, + [setUserInput] + ); const { amountString, @@ -50,9 +57,10 @@ export const OriginTokenInput = ({ handleBalanceClick, } = useTokenInput({ token: originToken, - setAmount: setOriginAmount, - expectedAmount, - shouldUpdate, + inputValue: quoteRequest.userInputValue, + setInputValue: handleSetInputValue, + isUserInput, + quoteOutputAmount: quoteRequest.quoteOutputAmount, isUpdateLoading, unit, setUnit, @@ -60,7 +68,7 @@ export const OriginTokenInput = ({ const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; - return Boolean(shouldUpdate && isUpdateLoading); + return Boolean(!isUserInput && isUpdateLoading); })(); const balance = useTokenBalance(quoteRequest?.originToken); diff --git a/src/views/SwapAndBridge/hooks/useButtonState.ts b/src/views/SwapAndBridge/hooks/useButtonState.ts index 01fd2b34c..aab91a1b2 100644 --- a/src/views/SwapAndBridge/hooks/useButtonState.ts +++ b/src/views/SwapAndBridge/hooks/useButtonState.ts @@ -117,14 +117,14 @@ export const useButtonState = ( !!validation.error || !quoteRequest.originToken || !quoteRequest.destinationToken || - !quoteRequest.amount || - quoteRequest.amount.lte(0), + !quoteRequest.userInputAmount || + quoteRequest.userInputAmount.lte(0), [ approvalAction.buttonDisabled, validation.error, quoteRequest.originToken, quoteRequest.destinationToken, - quoteRequest.amount, + quoteRequest.userInputAmount, ] ); diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts index 901d15760..f11a484df 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts @@ -28,24 +28,35 @@ describe("quoteRequestReducer", () => { expect(result.destinationToken).toBe(token); }); - it("sets origin amount with exactInput trade type", () => { + it("sets user input for origin field", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { - type: "SET_ORIGIN_AMOUNT", - payload: amount, + type: "SET_USER_INPUT", + payload: { field: "origin", value: "100", amount }, }); - expect(result.amount).toBe(amount); - expect(result.tradeType).toBe("exactInput"); + expect(result.userInputAmount).toBe(amount); + expect(result.userInputValue).toBe("100"); + expect(result.userInputField).toBe("origin"); }); - it("sets destination amount with minOutput trade type", () => { + it("sets user input for destination field", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { - type: "SET_DESTINATION_AMOUNT", + type: "SET_USER_INPUT", + payload: { field: "destination", value: "100", amount }, + }); + expect(result.userInputAmount).toBe(amount); + expect(result.userInputValue).toBe("100"); + expect(result.userInputField).toBe("destination"); + }); + + it("sets quote output amount", () => { + const amount = BigNumber.from(200); + const result = quoteRequestReducer(initialQuote, { + type: "SET_QUOTE_OUTPUT", payload: amount, }); - expect(result.amount).toBe(amount); - expect(result.tradeType).toBe("minOutput"); + expect(result.quoteOutputAmount).toBe(amount); }); it("sets custom destination account", () => { @@ -87,22 +98,28 @@ describe("quoteRequestReducer", () => { expect(result.destinationToken).toBe(eth); }); - it("converts exactInput to minOutput", () => { - const state: QuoteRequest = { ...initialQuote, tradeType: "exactInput" }; + it("converts origin input field to destination", () => { + const state: QuoteRequest = { + ...initialQuote, + userInputField: "origin", + }; const result = quoteRequestReducer(state, { type: "QUICK_SWAP", payload: undefined, }); - expect(result.tradeType).toBe("minOutput"); + expect(result.userInputField).toBe("destination"); }); - it("converts minOutput to exactInput", () => { - const state: QuoteRequest = { ...initialQuote, tradeType: "minOutput" }; + it("converts destination input field to origin", () => { + const state: QuoteRequest = { + ...initialQuote, + userInputField: "destination", + }; const result = quoteRequestReducer(state, { type: "QUICK_SWAP", payload: undefined, }); - expect(result.tradeType).toBe("exactInput"); + expect(result.userInputField).toBe("origin"); }); }); diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts index 8d1b3d9a0..e2f741070 100644 --- a/src/views/SwapAndBridge/hooks/useSwapQuote.ts +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -13,11 +13,11 @@ import { useEcosystemAccounts } from "../../../hooks/useEcosystemAccounts"; export type SwapQuote = ReturnType["swapQuote"]; const useSwapQuote = ({ - amount, + userInputAmount, + userInputField, customDestinationAccount, destinationToken, originToken, - tradeType, }: QuoteRequest) => { const { depositor, depositorOrPlaceholder, recipientOrPlaceholder } = useEcosystemAccounts({ @@ -26,7 +26,9 @@ const useSwapQuote = ({ customDestinationAccount, }); - const debouncedAmount = useDebounce(amount, 300); + const debouncedAmount = useDebounce(userInputAmount, 300); + + const tradeType = userInputField === "origin" ? "exactInput" : "minOutput"; const skipOriginTxEstimation = !depositor; @@ -34,12 +36,12 @@ const useSwapQuote = ({ queryKey: [ "swap-quote", debouncedAmount, + userInputField, customDestinationAccount?.address, destinationToken?.address, destinationToken?.chainId, originToken?.address, originToken?.chainId, - tradeType, depositor, depositorOrPlaceholder, recipientOrPlaceholder, diff --git a/src/views/SwapAndBridge/utils/balance.ts b/src/views/SwapAndBridge/utils/balance.ts index 6990dc4ff..d1ce042e9 100644 --- a/src/views/SwapAndBridge/utils/balance.ts +++ b/src/views/SwapAndBridge/utils/balance.ts @@ -2,19 +2,18 @@ import { QuoteRequest } from "../hooks/useQuoteRequest/quoteRequestAction"; import { BigNumber } from "ethers"; export const hasInsufficientBalance = ( - { amount, tradeType }: QuoteRequest, + { userInputAmount, userInputField }: QuoteRequest, expectedAmount: BigNumber | undefined, balance: BigNumber | undefined ) => { - if (!amount) { + if (!userInputAmount) { return false; } - if (tradeType === "exactInput" && balance) { - // isAmountorigin - if (amount.gt(balance)) { + if (userInputField === "origin" && balance) { + if (userInputAmount.gt(balance)) { return true; } - } else if (tradeType === "minOutput" && expectedAmount && balance) { + } else if (userInputField === "destination" && expectedAmount && balance) { if (expectedAmount.gt(balance)) { return true; } From 1bf7bc3a4ec6d819362f4cc19dddbcf48f028c14 Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 16:33:30 +0100 Subject: [PATCH 06/13] fix quote in state --- src/views/SwapAndBridge/SwapAndBridge.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/views/SwapAndBridge/SwapAndBridge.tsx b/src/views/SwapAndBridge/SwapAndBridge.tsx index 856932a03..f3068024f 100644 --- a/src/views/SwapAndBridge/SwapAndBridge.tsx +++ b/src/views/SwapAndBridge/SwapAndBridge.tsx @@ -6,13 +6,14 @@ import { useQuoteRequestContext, } from "./hooks/useQuoteRequest/QuoteRequestContext"; import { ConfirmationButton } from "./components/Confirmation/ConfirmationButton"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import useSwapQuote from "./hooks/useSwapQuote"; import { useDefaultRoute } from "./hooks/useDefaultRoute"; function SwapAndBridgeContent() { - const { quoteRequest, setOriginToken, setDestinationToken } = + const { quoteRequest, setOriginToken, setDestinationToken, setQuoteOutput } = useQuoteRequestContext(); + useDefaultRoute(setOriginToken, setDestinationToken); const { swapQuote, quoteError, isQuoteLoading } = useSwapQuote(quoteRequest); @@ -24,6 +25,19 @@ function SwapAndBridgeContent() { return swapQuote?.expectedOutputAmount; }, [swapQuote]); + useEffect(() => { + if (quoteRequest.userInputField === "origin") { + setQuoteOutput(expectedOutputAmount ?? null); + } else { + setQuoteOutput(expectedInputAmount ?? null); + } + }, [ + expectedInputAmount, + expectedOutputAmount, + quoteRequest.userInputField, + setQuoteOutput, + ]); + return ( Date: Mon, 15 Dec 2025 16:43:54 +0100 Subject: [PATCH 07/13] fix quote in state2 --- src/views/SwapAndBridge/SwapAndBridge.tsx | 28 +++---------------- .../SwapAndBridge/components/InputForm.tsx | 13 +-------- .../TokenInput/DestinationTokenDisplay.tsx | 2 -- .../TokenInput/OriginTokenInput.tsx | 8 +----- src/views/SwapAndBridge/utils/balance.ts | 7 ++--- 5 files changed, 9 insertions(+), 49 deletions(-) diff --git a/src/views/SwapAndBridge/SwapAndBridge.tsx b/src/views/SwapAndBridge/SwapAndBridge.tsx index f3068024f..e802f2a13 100644 --- a/src/views/SwapAndBridge/SwapAndBridge.tsx +++ b/src/views/SwapAndBridge/SwapAndBridge.tsx @@ -6,7 +6,7 @@ import { useQuoteRequestContext, } from "./hooks/useQuoteRequest/QuoteRequestContext"; import { ConfirmationButton } from "./components/Confirmation/ConfirmationButton"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import useSwapQuote from "./hooks/useSwapQuote"; import { useDefaultRoute } from "./hooks/useDefaultRoute"; @@ -18,33 +18,13 @@ function SwapAndBridgeContent() { const { swapQuote, quoteError, isQuoteLoading } = useSwapQuote(quoteRequest); - const expectedInputAmount = useMemo(() => { - return swapQuote?.inputAmount; - }, [swapQuote]); - const expectedOutputAmount = useMemo(() => { - return swapQuote?.expectedOutputAmount; - }, [swapQuote]); - useEffect(() => { - if (quoteRequest.userInputField === "origin") { - setQuoteOutput(expectedOutputAmount ?? null); - } else { - setQuoteOutput(expectedInputAmount ?? null); - } - }, [ - expectedInputAmount, - expectedOutputAmount, - quoteRequest.userInputField, - setQuoteOutput, - ]); + setQuoteOutput(swapQuote?.expectedOutputAmount ?? null); + }, [swapQuote, setQuoteOutput]); return ( - + { +export const InputForm = ({ isQuoteLoading }: { isQuoteLoading: boolean }) => { const { quickSwap } = useQuoteRequestContext(); const [unit, setUnit] = useState("token"); return ( void; }; export const DestinationTokenDisplay = ({ - expectedOutputAmount, isUpdateLoading, unit, setUnit, diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index a44412798..acc1a288e 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -21,14 +21,12 @@ import { hasInsufficientBalance } from "../../utils/balance"; import { useTokenBalance } from "views/SwapAndBridge/hooks/useTokenBalance"; type OriginTokenInputProps = { - expectedAmount: BigNumber | undefined; isUpdateLoading: boolean; unit: UnitType; setUnit: (unit: UnitType) => void; }; export const OriginTokenInput = ({ - expectedAmount, isUpdateLoading, unit, setUnit, @@ -73,11 +71,7 @@ export const OriginTokenInput = ({ const balance = useTokenBalance(quoteRequest?.originToken); - const insufficientBalance = hasInsufficientBalance( - quoteRequest, - expectedAmount, - balance - ); + const insufficientBalance = hasInsufficientBalance(quoteRequest, balance); useEffect(() => { if ( diff --git a/src/views/SwapAndBridge/utils/balance.ts b/src/views/SwapAndBridge/utils/balance.ts index d1ce042e9..5c5e85352 100644 --- a/src/views/SwapAndBridge/utils/balance.ts +++ b/src/views/SwapAndBridge/utils/balance.ts @@ -2,8 +2,7 @@ import { QuoteRequest } from "../hooks/useQuoteRequest/quoteRequestAction"; import { BigNumber } from "ethers"; export const hasInsufficientBalance = ( - { userInputAmount, userInputField }: QuoteRequest, - expectedAmount: BigNumber | undefined, + { userInputAmount, userInputField, quoteOutputAmount }: QuoteRequest, balance: BigNumber | undefined ) => { if (!userInputAmount) { @@ -13,8 +12,8 @@ export const hasInsufficientBalance = ( if (userInputAmount.gt(balance)) { return true; } - } else if (userInputField === "destination" && expectedAmount && balance) { - if (expectedAmount.gt(balance)) { + } else if (userInputField === "destination" && quoteOutputAmount && balance) { + if (quoteOutputAmount.gt(balance)) { return true; } } From fcbf259fd3d3b381948e994754bb6c97a9fb27dc Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 16:59:50 +0100 Subject: [PATCH 08/13] remove more stuff --- src/hooks/useTokenInput.ts | 14 ++++---------- .../components/TokenInput/OriginTokenInput.tsx | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index 4aa50d50a..fdf0f447c 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -18,13 +18,12 @@ type UseTokenInputProps = { isUserInput: boolean; quoteOutputAmount: BigNumber | null | undefined; isUpdateLoading: boolean; - unit?: UnitType; - setUnit?: (unit: UnitType) => void; + unit: UnitType; + setUnit: (unit: UnitType) => void; }; type UseTokenInputReturn = { amountString: string; - unit: UnitType; convertedAmount: BigNumber | undefined; toggleUnit: () => void; handleInputChange: (value: string) => void; @@ -38,15 +37,11 @@ export function useTokenInput({ isUserInput, quoteOutputAmount, isUpdateLoading, - unit: externalUnit, - setUnit: externalSetUnit, + unit, + setUnit, }: UseTokenInputProps): UseTokenInputReturn { - const [internalUnit, setInternalUnit] = useState("token"); const [convertedAmount, setConvertedAmount] = useState(); - const unit = externalUnit ?? internalUnit; - const setUnit = externalSetUnit ?? setInternalUnit; - const displayValue = useMemo(() => { if (!isUserInput && isUpdateLoading) { return ""; @@ -140,7 +135,6 @@ export function useTokenInput({ return { amountString: displayValue, - unit, convertedAmount, toggleUnit, handleInputChange, diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index acc1a288e..6b86b3630 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatUSD } from "utils"; +import { formatAmountForDisplay, formatUSD } from "utils"; import { UnitType, useTokenInput } from "hooks"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; From 71d2628e370a8f57cdc084c670f838e78ea313fa Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 17:40:08 +0100 Subject: [PATCH 09/13] refactor again --- src/hooks/useTokenInput.ts | 38 +++---------- .../TokenInput/DestinationTokenDisplay.tsx | 57 ++++++++++++------- .../TokenInput/OriginTokenInput.tsx | 57 ++++++++++++------- 3 files changed, 79 insertions(+), 73 deletions(-) diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index fdf0f447c..f734ecfdf 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -5,7 +5,6 @@ import { convertUSDToToken, formatAmountForDisplay, isValidNumberInput, - parseInputValue, } from "utils"; import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; @@ -14,7 +13,7 @@ export type UnitType = "usd" | "token"; type UseTokenInputProps = { token: EnrichedToken | null; inputValue: string; - setInputValue: (value: string, amount: BigNumber | null) => void; + setInputValue: (value: string) => void; isUserInput: boolean; quoteOutputAmount: BigNumber | null | undefined; isUpdateLoading: boolean; @@ -27,7 +26,6 @@ type UseTokenInputReturn = { convertedAmount: BigNumber | undefined; toggleUnit: () => void; handleInputChange: (value: string) => void; - handleBalanceClick: (amount: BigNumber, decimals: number) => void; }; export function useTokenInput({ @@ -82,10 +80,9 @@ export function useTokenInput({ if (inputValue && token && convertedAmount) { try { const a = utils.formatUnits(convertedAmount, 18); - const parsed = parseInputValue(a, token, "usd"); - setInputValue(a, parsed); + setInputValue(a); } catch (e) { - setInputValue("0", null); + setInputValue("0"); } } setUnit("usd"); @@ -93,10 +90,9 @@ export function useTokenInput({ if (inputValue && token && convertedAmount) { try { const a = utils.formatUnits(convertedAmount, token.decimals); - const parsed = parseInputValue(a, token, "token"); - setInputValue(a, parsed); + setInputValue(a); } catch (e) { - setInputValue("0", null); + setInputValue("0"); } } setUnit("token"); @@ -109,28 +105,9 @@ export function useTokenInput({ return; } - if (!token) { - setInputValue(value, null); - return; - } - - try { - const parsed = parseInputValue(value, token, unit); - setInputValue(value, parsed); - } catch (e) { - setInputValue(value, null); - } - }, - [token, unit, setInputValue] - ); - - const handleBalanceClick = useCallback( - (amount: BigNumber, _decimals: number) => { - if (token) { - setInputValue(formatAmountForDisplay(amount, token, unit), amount); - } + setInputValue(value); }, - [unit, token, setInputValue] + [setInputValue] ); return { @@ -138,6 +115,5 @@ export function useTokenInput({ convertedAmount, toggleUnit, handleInputChange, - handleBalanceClick, }; } diff --git a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx index c702ee24c..91e660d15 100644 --- a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatUSD } from "utils"; +import { formatAmountForDisplay, formatUSD, parseInputValue } from "utils"; import { UnitType, useTokenInput } from "hooks"; import { ChangeAccountModal } from "../ChangeAccountModal"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; @@ -38,28 +38,43 @@ export const DestinationTokenDisplay = ({ const isUserInput = quoteRequest.userInputField === "destination"; const handleSetInputValue = useCallback( - (value: string, amount: BigNumber | null) => { - setUserInput("destination", value, amount); + (value: string) => { + if (!destinationToken) { + setUserInput("destination", value, null); + return; + } + + try { + const parsed = parseInputValue(value, destinationToken, unit); + setUserInput("destination", value, parsed); + } catch (e) { + setUserInput("destination", value, null); + } + }, + [setUserInput, destinationToken, unit] + ); + + const handleBalanceClick = useCallback( + (amount: BigNumber) => { + if (!destinationToken) return; + + const formatted = formatAmountForDisplay(amount, destinationToken, unit); + setUserInput("destination", formatted, amount); }, - [setUserInput] + [destinationToken, unit, setUserInput] ); - const { - amountString, - convertedAmount, - toggleUnit, - handleInputChange, - handleBalanceClick, - } = useTokenInput({ - token: destinationToken, - inputValue: quoteRequest.userInputValue, - setInputValue: handleSetInputValue, - isUserInput, - quoteOutputAmount: quoteRequest.quoteOutputAmount, - isUpdateLoading, - unit, - setUnit, - }); + const { amountString, convertedAmount, toggleUnit, handleInputChange } = + useTokenInput({ + token: destinationToken, + inputValue: quoteRequest.userInputValue, + setInputValue: handleSetInputValue, + isUserInput, + quoteOutputAmount: quoteRequest.quoteOutputAmount, + isUpdateLoading, + unit, + setUnit, + }); const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; @@ -122,7 +137,7 @@ export const DestinationTokenDisplay = ({ error={false} setAmount={(amount) => { if (amount) { - handleBalanceClick(amount, destinationToken.decimals); + handleBalanceClick(amount); } }} /> diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index 6b86b3630..ac4f79134 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatAmountForDisplay, formatUSD } from "utils"; +import { formatAmountForDisplay, formatUSD, parseInputValue } from "utils"; import { UnitType, useTokenInput } from "hooks"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; @@ -41,28 +41,43 @@ export const OriginTokenInput = ({ const isUserInput = quoteRequest.userInputField === "origin"; const handleSetInputValue = useCallback( - (value: string, amount: BigNumber | null) => { - setUserInput("origin", value, amount); + (value: string) => { + if (!originToken) { + setUserInput("origin", value, null); + return; + } + + try { + const parsed = parseInputValue(value, originToken, unit); + setUserInput("origin", value, parsed); + } catch (e) { + setUserInput("origin", value, null); + } + }, + [setUserInput, originToken, unit] + ); + + const handleBalanceClick = useCallback( + (amount: BigNumber) => { + if (!originToken) return; + + const formatted = formatAmountForDisplay(amount, originToken, unit); + setUserInput("origin", formatted, amount); }, - [setUserInput] + [originToken, unit, setUserInput] ); - const { - amountString, - convertedAmount, - toggleUnit, - handleInputChange, - handleBalanceClick, - } = useTokenInput({ - token: originToken, - inputValue: quoteRequest.userInputValue, - setInputValue: handleSetInputValue, - isUserInput, - quoteOutputAmount: quoteRequest.quoteOutputAmount, - isUpdateLoading, - unit, - setUnit, - }); + const { amountString, convertedAmount, toggleUnit, handleInputChange } = + useTokenInput({ + token: originToken, + inputValue: quoteRequest.userInputValue, + setInputValue: handleSetInputValue, + isUserInput, + quoteOutputAmount: quoteRequest.quoteOutputAmount, + isUpdateLoading, + unit, + setUnit, + }); const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; @@ -139,7 +154,7 @@ export const OriginTokenInput = ({ error={insufficientBalance} setAmount={(amount) => { if (amount) { - handleBalanceClick(amount, originToken.decimals); + handleBalanceClick(amount); } }} /> From cc2422429d4de75077b99fca54bbb4b4ba7b8fdc Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 18:52:30 +0100 Subject: [PATCH 10/13] big refactor --- src/hooks/index.ts | 1 - src/hooks/useTokenInput.ts | 119 -------------- src/utils/token.ts | 2 +- .../ConfirmationButton.stories.tsx | 1 - .../SwapAndBridge/components/InputForm.tsx | 3 +- .../TokenInput/DestinationTokenDisplay.tsx | 151 ++++++++++++++--- .../TokenInput/OriginTokenInput.tsx | 153 +++++++++++++++--- .../useQuoteRequest/QuoteRequestContext.tsx | 9 +- .../hooks/useQuoteRequest/initialQuote.ts | 1 - .../useQuoteRequest/quoteRequestAction.ts | 2 - .../quoteRequestReducer.test.ts | 6 +- .../useQuoteRequest/quoteRequestReducer.ts | 3 - 12 files changed, 263 insertions(+), 188 deletions(-) delete mode 100644 src/hooks/useTokenInput.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 517143af7..016bd9721 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -18,4 +18,3 @@ export * from "./useQueue"; export * from "./useAmplitude"; export * from "./useRewardSummary"; export * from "./feature-flags/useFeatureFlag"; -export * from "./useTokenInput"; diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts deleted file mode 100644 index f734ecfdf..000000000 --- a/src/hooks/useTokenInput.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { BigNumber, utils } from "ethers"; -import { - convertTokenToUSD, - convertUSDToToken, - formatAmountForDisplay, - isValidNumberInput, -} from "utils"; -import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; - -export type UnitType = "usd" | "token"; - -type UseTokenInputProps = { - token: EnrichedToken | null; - inputValue: string; - setInputValue: (value: string) => void; - isUserInput: boolean; - quoteOutputAmount: BigNumber | null | undefined; - isUpdateLoading: boolean; - unit: UnitType; - setUnit: (unit: UnitType) => void; -}; - -type UseTokenInputReturn = { - amountString: string; - convertedAmount: BigNumber | undefined; - toggleUnit: () => void; - handleInputChange: (value: string) => void; -}; - -export function useTokenInput({ - token, - inputValue, - setInputValue, - isUserInput, - quoteOutputAmount, - isUpdateLoading, - unit, - setUnit, -}: UseTokenInputProps): UseTokenInputReturn { - const [convertedAmount, setConvertedAmount] = useState(); - - const displayValue = useMemo(() => { - if (!isUserInput && isUpdateLoading) { - return ""; - } - if (!isUserInput && quoteOutputAmount && token) { - return formatAmountForDisplay(quoteOutputAmount, token, unit); - } - return inputValue; - }, [ - isUserInput, - isUpdateLoading, - quoteOutputAmount, - token, - unit, - inputValue, - ]); - - useEffect(() => { - if (!token || !displayValue) { - setConvertedAmount(undefined); - return; - } - try { - if (unit === "token") { - const usdValue = convertTokenToUSD(displayValue, token); - setConvertedAmount(usdValue); - } else { - const tokenValue = convertUSDToToken(displayValue, token); - setConvertedAmount(tokenValue); - } - } catch (e) { - setConvertedAmount(undefined); - } - }, [token, displayValue, unit]); - - const toggleUnit = useCallback(() => { - if (unit === "token") { - if (inputValue && token && convertedAmount) { - try { - const a = utils.formatUnits(convertedAmount, 18); - setInputValue(a); - } catch (e) { - setInputValue("0"); - } - } - setUnit("usd"); - } else { - if (inputValue && token && convertedAmount) { - try { - const a = utils.formatUnits(convertedAmount, token.decimals); - setInputValue(a); - } catch (e) { - setInputValue("0"); - } - } - setUnit("token"); - } - }, [unit, inputValue, token, convertedAmount, setUnit, setInputValue]); - - const handleInputChange = useCallback( - (value: string) => { - if (!isValidNumberInput(value)) { - return; - } - - setInputValue(value); - }, - [setInputValue] - ); - - return { - amountString: displayValue, - convertedAmount, - toggleUnit, - handleInputChange, - }; -} diff --git a/src/utils/token.ts b/src/utils/token.ts index 0f3dc9599..f27edecdc 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -14,7 +14,7 @@ import { SwapToken } from "utils/serverless-api/types"; import { TokenInfo } from "constants/tokens"; import { chainsWithUsdt0Enabled, getToken, tokenTable } from "utils/constants"; import usdt0Logo from "assets/token-logos/usdt0.svg"; -import { UnitType } from "hooks/useTokenInput"; +import { UnitType } from "views/SwapAndBridge/components/TokenInput/OriginTokenInput"; export async function getNativeBalance( chainId: ChainId, diff --git a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx index 5a228d381..69d4e6efa 100644 --- a/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx +++ b/src/views/SwapAndBridge/components/Confirmation/ConfirmationButton.stories.tsx @@ -186,7 +186,6 @@ const mockSwapQuote: SwapApprovalApiCallReturnType = { const mockQuoteRequest: QuoteRequest = { userInputField: "origin", - userInputValue: "100", userInputAmount: BigNumber.from("100000000"), quoteOutputAmount: BigNumber.from("99500000"), originToken: mockInputToken, diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 0680009f0..7cfaf3f7b 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -2,9 +2,8 @@ import { COLORS } from "utils"; import styled from "@emotion/styled"; import { useState } from "react"; import { ReactComponent as ArrowDown } from "assets/icons/arrow-down.svg"; -import { UnitType } from "hooks"; import { useQuoteRequestContext } from "../hooks/useQuoteRequest/QuoteRequestContext"; -import { OriginTokenInput } from "./TokenInput/OriginTokenInput"; +import { OriginTokenInput, UnitType } from "./TokenInput/OriginTokenInput"; import { DestinationTokenDisplay } from "./TokenInput/DestinationTokenDisplay"; export const InputForm = ({ isQuoteLoading }: { isQuoteLoading: boolean }) => { diff --git a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx index 91e660d15..f9ef019a6 100644 --- a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenDisplay.tsx @@ -1,8 +1,14 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatAmountForDisplay, formatUSD, parseInputValue } from "utils"; -import { UnitType, useTokenInput } from "hooks"; +import { + convertTokenToUSD, + convertUSDToToken, + formatAmountForDisplay, + formatUSD, + isValidNumberInput, + parseInputValue, +} from "utils"; import { ChangeAccountModal } from "../ChangeAccountModal"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; @@ -19,6 +25,8 @@ import { import { useQuoteRequestContext } from "../../hooks/useQuoteRequest/QuoteRequestContext"; import { BigNumber } from "ethers"; +export type UnitType = "usd" | "token"; + type DestinationTokenDisplayProps = { isUpdateLoading: boolean; unit: UnitType; @@ -33,6 +41,9 @@ export const DestinationTokenDisplay = ({ const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = useQuoteRequestContext(); + const [inputBuffer, setInputBuffer] = useState(""); + const [isUserTyping, setIsUserTyping] = useState(false); + const { destinationToken, originToken } = quoteRequest; const isUserInput = quoteRequest.userInputField === "destination"; @@ -40,15 +51,15 @@ export const DestinationTokenDisplay = ({ const handleSetInputValue = useCallback( (value: string) => { if (!destinationToken) { - setUserInput("destination", value, null); + setUserInput("destination", null); return; } try { const parsed = parseInputValue(value, destinationToken, unit); - setUserInput("destination", value, parsed); + setUserInput("destination", parsed); } catch (e) { - setUserInput("destination", value, null); + setUserInput("destination", null); } }, [setUserInput, destinationToken, unit] @@ -58,37 +69,130 @@ export const DestinationTokenDisplay = ({ (amount: BigNumber) => { if (!destinationToken) return; + setUserInput("destination", amount); + setIsUserTyping(false); + setInputBuffer(""); + }, + [destinationToken, setUserInput] + ); + + const displayValue = useMemo(() => { + if (isUserTyping) { + return inputBuffer; + } + + if (!isUserInput && isUpdateLoading) { + return ""; + } + + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!amount || !destinationToken) { + return ""; + } + + return formatAmountForDisplay(amount, destinationToken, unit); + }, [ + isUserTyping, + inputBuffer, + isUserInput, + isUpdateLoading, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + destinationToken, + unit, + ]); + + const [convertedAmount, setConvertedAmount] = useState(); + + useEffect(() => { + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!destinationToken || !amount) { + setConvertedAmount(undefined); + return; + } + + try { const formatted = formatAmountForDisplay(amount, destinationToken, unit); - setUserInput("destination", formatted, amount); + if (unit === "token") { + const usdValue = convertTokenToUSD(formatted, destinationToken); + setConvertedAmount(usdValue); + } else { + const tokenValue = convertUSDToToken(formatted, destinationToken); + setConvertedAmount(tokenValue); + } + } catch (e) { + setConvertedAmount(undefined); + } + }, [ + destinationToken, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + unit, + isUserInput, + ]); + + const toggleUnit = useCallback(() => { + if (!destinationToken || !convertedAmount) { + setUnit(unit === "token" ? "usd" : "token"); + return; + } + + const newUnit = unit === "token" ? "usd" : "token"; + setUnit(newUnit); + + if (isUserInput && quoteRequest.userInputAmount) { + setUserInput("destination", convertedAmount); + } + + setIsUserTyping(false); + setInputBuffer(""); + }, [ + unit, + destinationToken, + convertedAmount, + isUserInput, + quoteRequest.userInputAmount, + setUnit, + setUserInput, + ]); + + const handleInputChange = useCallback( + (value: string) => { + if (!isValidNumberInput(value)) { + return; + } + + setIsUserTyping(true); + setInputBuffer(value); + handleSetInputValue(value); }, - [destinationToken, unit, setUserInput] + [handleSetInputValue] ); - const { amountString, convertedAmount, toggleUnit, handleInputChange } = - useTokenInput({ - token: destinationToken, - inputValue: quoteRequest.userInputValue, - setInputValue: handleSetInputValue, - isUserInput, - quoteOutputAmount: quoteRequest.quoteOutputAmount, - isUpdateLoading, - unit, - setUnit, - }); + const handleBlur = useCallback(() => { + setIsUserTyping(false); + setInputBuffer(""); + }, []); const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; return Boolean(!isUserInput && isUpdateLoading); })(); - const formattedConvertedAmount = (() => { + const formattedConvertedAmount = useMemo(() => { if (unit === "token") { if (!convertedAmount) return "$0.00"; return "$" + formatUSD(convertedAmount); } if (!convertedAmount) return "0.00"; return `${formatUnits(convertedAmount, destinationToken?.decimals)} ${destinationToken?.symbol}`; - })(); + }, [unit, convertedAmount, destinationToken]); return ( @@ -100,15 +204,16 @@ export const DestinationTokenDisplay = ({ handleInputChange(e.target.value)} + onBlur={handleBlur} disabled={inputDisabled} error={false} /> diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index ac4f79134..35a2e9dfd 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,8 +1,14 @@ -import { useCallback, useEffect, useRef } from "react"; -import { formatUnits } from "ethers/lib/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { formatUnits, parseUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatAmountForDisplay, formatUSD, parseInputValue } from "utils"; -import { UnitType, useTokenInput } from "hooks"; +import { + convertTokenToUSD, + convertUSDToToken, + formatAmountForDisplay, + formatUSD, + isValidNumberInput, + parseInputValue, +} from "utils"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; import { @@ -20,6 +26,8 @@ import { BigNumber } from "ethers"; import { hasInsufficientBalance } from "../../utils/balance"; import { useTokenBalance } from "views/SwapAndBridge/hooks/useTokenBalance"; +export type UnitType = "usd" | "token"; + type OriginTokenInputProps = { isUpdateLoading: boolean; unit: UnitType; @@ -36,6 +44,9 @@ export const OriginTokenInput = ({ const amountInputRef = useRef(null); const hasAutoFocusedRef = useRef(false); + const [inputBuffer, setInputBuffer] = useState(""); + const [isUserTyping, setIsUserTyping] = useState(false); + const { originToken, destinationToken } = quoteRequest; const isUserInput = quoteRequest.userInputField === "origin"; @@ -43,15 +54,15 @@ export const OriginTokenInput = ({ const handleSetInputValue = useCallback( (value: string) => { if (!originToken) { - setUserInput("origin", value, null); + setUserInput("origin", null); return; } try { const parsed = parseInputValue(value, originToken, unit); - setUserInput("origin", value, parsed); + setUserInput("origin", parsed); } catch (e) { - setUserInput("origin", value, null); + setUserInput("origin", null); } }, [setUserInput, originToken, unit] @@ -61,23 +72,116 @@ export const OriginTokenInput = ({ (amount: BigNumber) => { if (!originToken) return; + setUserInput("origin", amount); + setIsUserTyping(false); + setInputBuffer(""); + }, + [originToken, setUserInput] + ); + + const displayValue = useMemo(() => { + if (isUserTyping) { + return inputBuffer; + } + + if (!isUserInput && isUpdateLoading) { + return ""; + } + + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!amount || !originToken) { + return ""; + } + + return formatAmountForDisplay(amount, originToken, unit); + }, [ + isUserTyping, + inputBuffer, + isUserInput, + isUpdateLoading, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + originToken, + unit, + ]); + + const [convertedAmount, setConvertedAmount] = useState(); + + useEffect(() => { + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!originToken || !amount) { + setConvertedAmount(undefined); + return; + } + + try { const formatted = formatAmountForDisplay(amount, originToken, unit); - setUserInput("origin", formatted, amount); + if (unit === "token") { + const usdValue = convertTokenToUSD(formatted, originToken); + setConvertedAmount(usdValue); + } else { + const tokenValue = convertUSDToToken(formatted, originToken); + setConvertedAmount(tokenValue); + } + } catch (e) { + setConvertedAmount(undefined); + } + }, [ + originToken, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + unit, + isUserInput, + ]); + + const toggleUnit = useCallback(() => { + if (!originToken || !convertedAmount) { + setUnit(unit === "token" ? "usd" : "token"); + return; + } + + const newUnit = unit === "token" ? "usd" : "token"; + setUnit(newUnit); + + if (isUserInput && quoteRequest.userInputAmount) { + setUserInput("origin", convertedAmount); + } + + setIsUserTyping(false); + setInputBuffer(""); + }, [ + unit, + originToken, + convertedAmount, + isUserInput, + quoteRequest.userInputAmount, + setUnit, + setUserInput, + ]); + + const handleInputChange = useCallback( + (value: string) => { + if (!isValidNumberInput(value)) { + return; + } + + setIsUserTyping(true); + setInputBuffer(value); + handleSetInputValue(value); }, - [originToken, unit, setUserInput] + [handleSetInputValue] ); - const { amountString, convertedAmount, toggleUnit, handleInputChange } = - useTokenInput({ - token: originToken, - inputValue: quoteRequest.userInputValue, - setInputValue: handleSetInputValue, - isUserInput, - quoteOutputAmount: quoteRequest.quoteOutputAmount, - isUpdateLoading, - unit, - setUnit, - }); + const handleBlur = useCallback(() => { + setIsUserTyping(false); + setInputBuffer(""); + }, []); const inputDisabled = (() => { if (!quoteRequest.destinationToken) return true; @@ -99,14 +203,14 @@ export const OriginTokenInput = ({ } }, [inputDisabled]); - const formattedConvertedAmount = (() => { + const formattedConvertedAmount = useMemo(() => { if (unit === "token") { if (!convertedAmount) return "$0.00"; return "$" + formatUSD(convertedAmount); } if (!convertedAmount) return "0.00"; return `${formatUnits(convertedAmount, originToken?.decimals)} ${originToken?.symbol}`; - })(); + }, [unit, convertedAmount, originToken]); return ( @@ -115,7 +219,7 @@ export const OriginTokenInput = ({ handleInputChange(e.target.value)} + onBlur={handleBlur} disabled={inputDisabled} error={insufficientBalance} /> diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx index 7833d261c..aff482753 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx @@ -11,7 +11,6 @@ interface QuoteRequestContextValue { setDestinationToken: (token: EnrichedToken | null) => void; setUserInput: ( field: "origin" | "destination", - value: string, amount: BigNumber | null ) => void; setQuoteOutput: (amount: BigNumber | null) => void; @@ -45,12 +44,8 @@ export const QuoteRequestProvider = ({ }, []); const setUserInput = useCallback( - ( - field: "origin" | "destination", - value: string, - amount: BigNumber | null - ) => { - dispatch({ type: "SET_USER_INPUT", payload: { field, value, amount } }); + (field: "origin" | "destination", amount: BigNumber | null) => { + dispatch({ type: "SET_USER_INPUT", payload: { field, amount } }); }, [] ); diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts index f3ae9d7fb..c69b69f86 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts @@ -5,7 +5,6 @@ export const initialQuote: QuoteRequest = { destinationToken: null, customDestinationAccount: null, userInputField: "origin", - userInputValue: "", userInputAmount: null, quoteOutputAmount: null, }; diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts index d4999c284..30ff35f27 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts @@ -17,7 +17,6 @@ export type QuoteRequestAction = type: "SET_USER_INPUT"; payload: { field: "origin" | "destination"; - value: string; amount: BigNumber | null; }; } @@ -34,7 +33,6 @@ export interface QuoteRequest { destinationToken: EnrichedToken | null; customDestinationAccount: QuoteAccount | null; userInputField: "origin" | "destination"; - userInputValue: string; userInputAmount: BigNumber | null; quoteOutputAmount: BigNumber | null; } diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts index f11a484df..f85a38c67 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts @@ -32,10 +32,9 @@ describe("quoteRequestReducer", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { type: "SET_USER_INPUT", - payload: { field: "origin", value: "100", amount }, + payload: { field: "origin", amount }, }); expect(result.userInputAmount).toBe(amount); - expect(result.userInputValue).toBe("100"); expect(result.userInputField).toBe("origin"); }); @@ -43,10 +42,9 @@ describe("quoteRequestReducer", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { type: "SET_USER_INPUT", - payload: { field: "destination", value: "100", amount }, + payload: { field: "destination", amount }, }); expect(result.userInputAmount).toBe(amount); - expect(result.userInputValue).toBe("100"); expect(result.userInputField).toBe("destination"); }); diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts index 4a7419f73..3dcfb9f96 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts @@ -9,7 +9,6 @@ export const quoteRequestReducer = ( return { ...prevState, originToken: action.payload, - userInputValue: "", userInputAmount: null, quoteOutputAmount: null, }; @@ -17,7 +16,6 @@ export const quoteRequestReducer = ( return { ...prevState, destinationToken: action.payload, - userInputValue: "", userInputAmount: null, quoteOutputAmount: null, }; @@ -25,7 +23,6 @@ export const quoteRequestReducer = ( return { ...prevState, userInputField: action.payload.field, - userInputValue: action.payload.value, userInputAmount: action.payload.amount, }; case "SET_QUOTE_OUTPUT": From 2128c25a29a0f0aa7137752ea362875a7d9dd8ce Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 18:53:35 +0100 Subject: [PATCH 11/13] rename --- src/views/SwapAndBridge/components/InputForm.tsx | 4 ++-- ...{DestinationTokenDisplay.tsx => DestinationTokenInput.tsx} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/views/SwapAndBridge/components/TokenInput/{DestinationTokenDisplay.tsx => DestinationTokenInput.tsx} (99%) diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 7cfaf3f7b..c55595e3c 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { ReactComponent as ArrowDown } from "assets/icons/arrow-down.svg"; import { useQuoteRequestContext } from "../hooks/useQuoteRequest/QuoteRequestContext"; import { OriginTokenInput, UnitType } from "./TokenInput/OriginTokenInput"; -import { DestinationTokenDisplay } from "./TokenInput/DestinationTokenDisplay"; +import { DestinationTokenInput } from "./TokenInput/DestinationTokenInput"; export const InputForm = ({ isQuoteLoading }: { isQuoteLoading: boolean }) => { const { quickSwap } = useQuoteRequestContext(); @@ -20,7 +20,7 @@ export const InputForm = ({ isQuoteLoading }: { isQuoteLoading: boolean }) => { - void; }; -export const DestinationTokenDisplay = ({ +export const DestinationTokenInput = ({ isUpdateLoading, unit, setUnit, From a6d7c427c1e4365bfd735327ac33d183b9899cbd Mon Sep 17 00:00:00 2001 From: jorgen Date: Mon, 15 Dec 2025 19:24:33 +0100 Subject: [PATCH 12/13] extract hook --- package.json | 1 + .../TokenInput/DestinationTokenInput.tsx | 173 ++-------------- .../TokenInput/OriginTokenInput.tsx | 174 ++-------------- src/views/SwapAndBridge/hooks/index.ts | 1 + .../hooks/useTokenAmountInput.ts | 188 ++++++++++++++++++ yarn.lock | 5 + 6 files changed, 224 insertions(+), 318 deletions(-) create mode 100644 src/views/SwapAndBridge/hooks/index.ts create mode 100644 src/views/SwapAndBridge/hooks/useTokenAmountInput.ts diff --git a/package.json b/package.json index b348e8c54..2b3be0499 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react-dom": "v18", "react-feather": "^2.0.9", "react-hotkeys-hook": "^5.1.0", + "react-number-format": "^5.4.4", "react-pro-sidebar": "^1.1.0", "react-router-dom": "v5", "react-tooltip": "^5.18.0", diff --git a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenInput.tsx index eb682b481..087f270e0 100644 --- a/src/views/SwapAndBridge/components/TokenInput/DestinationTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/DestinationTokenInput.tsx @@ -1,14 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { - convertTokenToUSD, - convertUSDToToken, - formatAmountForDisplay, - formatUSD, - isValidNumberInput, - parseInputValue, -} from "utils"; import { ChangeAccountModal } from "../ChangeAccountModal"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; @@ -23,7 +13,7 @@ import { UnitToggleButtonWrapper, } from "./styles"; import { useQuoteRequestContext } from "../../hooks/useQuoteRequest/QuoteRequestContext"; -import { BigNumber } from "ethers"; +import { useTokenAmountInput } from "../../hooks"; export type UnitType = "usd" | "token"; @@ -41,158 +31,24 @@ export const DestinationTokenInput = ({ const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = useQuoteRequestContext(); - const [inputBuffer, setInputBuffer] = useState(""); - const [isUserTyping, setIsUserTyping] = useState(false); - const { destinationToken, originToken } = quoteRequest; - const isUserInput = quoteRequest.userInputField === "destination"; - - const handleSetInputValue = useCallback( - (value: string) => { - if (!destinationToken) { - setUserInput("destination", null); - return; - } - - try { - const parsed = parseInputValue(value, destinationToken, unit); - setUserInput("destination", parsed); - } catch (e) { - setUserInput("destination", null); - } - }, - [setUserInput, destinationToken, unit] - ); - - const handleBalanceClick = useCallback( - (amount: BigNumber) => { - if (!destinationToken) return; - - setUserInput("destination", amount); - setIsUserTyping(false); - setInputBuffer(""); - }, - [destinationToken, setUserInput] - ); - - const displayValue = useMemo(() => { - if (isUserTyping) { - return inputBuffer; - } - - if (!isUserInput && isUpdateLoading) { - return ""; - } - - const amount = isUserInput - ? quoteRequest.userInputAmount - : quoteRequest.quoteOutputAmount; - - if (!amount || !destinationToken) { - return ""; - } - - return formatAmountForDisplay(amount, destinationToken, unit); - }, [ - isUserTyping, - inputBuffer, - isUserInput, - isUpdateLoading, - quoteRequest.userInputAmount, - quoteRequest.quoteOutputAmount, - destinationToken, + const { + displayValue, + formattedConvertedAmount, + inputDisabled, + handleInputChange, + handleBalanceClick, + toggleUnit, + } = useTokenAmountInput({ + token: destinationToken, + fieldName: "destination", unit, - ]); - - const [convertedAmount, setConvertedAmount] = useState(); - - useEffect(() => { - const amount = isUserInput - ? quoteRequest.userInputAmount - : quoteRequest.quoteOutputAmount; - - if (!destinationToken || !amount) { - setConvertedAmount(undefined); - return; - } - - try { - const formatted = formatAmountForDisplay(amount, destinationToken, unit); - if (unit === "token") { - const usdValue = convertTokenToUSD(formatted, destinationToken); - setConvertedAmount(usdValue); - } else { - const tokenValue = convertUSDToToken(formatted, destinationToken); - setConvertedAmount(tokenValue); - } - } catch (e) { - setConvertedAmount(undefined); - } - }, [ - destinationToken, - quoteRequest.userInputAmount, - quoteRequest.quoteOutputAmount, - unit, - isUserInput, - ]); - - const toggleUnit = useCallback(() => { - if (!destinationToken || !convertedAmount) { - setUnit(unit === "token" ? "usd" : "token"); - return; - } - - const newUnit = unit === "token" ? "usd" : "token"; - setUnit(newUnit); - - if (isUserInput && quoteRequest.userInputAmount) { - setUserInput("destination", convertedAmount); - } - - setIsUserTyping(false); - setInputBuffer(""); - }, [ - unit, - destinationToken, - convertedAmount, - isUserInput, - quoteRequest.userInputAmount, setUnit, + isUpdateLoading, setUserInput, - ]); - - const handleInputChange = useCallback( - (value: string) => { - if (!isValidNumberInput(value)) { - return; - } - - setIsUserTyping(true); - setInputBuffer(value); - handleSetInputValue(value); - }, - [handleSetInputValue] - ); - - const handleBlur = useCallback(() => { - setIsUserTyping(false); - setInputBuffer(""); - }, []); - - const inputDisabled = (() => { - if (!quoteRequest.destinationToken) return true; - return Boolean(!isUserInput && isUpdateLoading); - })(); - - const formattedConvertedAmount = useMemo(() => { - if (unit === "token") { - if (!convertedAmount) return "$0.00"; - return "$" + formatUSD(convertedAmount); - } - if (!convertedAmount) return "0.00"; - return `${formatUnits(convertedAmount, destinationToken?.decimals)} ${destinationToken?.symbol}`; - }, [unit, convertedAmount, destinationToken]); + quoteRequest, + }); return ( @@ -213,7 +69,6 @@ export const DestinationTokenInput = ({ placeholder="0.00" value={displayValue} onChange={(e) => handleInputChange(e.target.value)} - onBlur={handleBlur} disabled={inputDisabled} error={false} /> diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index 35a2e9dfd..739d5c34d 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,14 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { formatUnits, parseUnits } from "ethers/lib/utils"; +import { useEffect, useRef } from "react"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { - convertTokenToUSD, - convertUSDToToken, - formatAmountForDisplay, - formatUSD, - isValidNumberInput, - parseInputValue, -} from "utils"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; import { @@ -22,9 +13,9 @@ import { UnitToggleButtonWrapper, } from "./styles"; import { useQuoteRequestContext } from "../../hooks/useQuoteRequest/QuoteRequestContext"; -import { BigNumber } from "ethers"; import { hasInsufficientBalance } from "../../utils/balance"; import { useTokenBalance } from "views/SwapAndBridge/hooks/useTokenBalance"; +import { useTokenAmountInput } from "../../hooks"; export type UnitType = "usd" | "token"; @@ -44,149 +35,24 @@ export const OriginTokenInput = ({ const amountInputRef = useRef(null); const hasAutoFocusedRef = useRef(false); - const [inputBuffer, setInputBuffer] = useState(""); - const [isUserTyping, setIsUserTyping] = useState(false); - const { originToken, destinationToken } = quoteRequest; - const isUserInput = quoteRequest.userInputField === "origin"; - - const handleSetInputValue = useCallback( - (value: string) => { - if (!originToken) { - setUserInput("origin", null); - return; - } - - try { - const parsed = parseInputValue(value, originToken, unit); - setUserInput("origin", parsed); - } catch (e) { - setUserInput("origin", null); - } - }, - [setUserInput, originToken, unit] - ); - - const handleBalanceClick = useCallback( - (amount: BigNumber) => { - if (!originToken) return; - - setUserInput("origin", amount); - setIsUserTyping(false); - setInputBuffer(""); - }, - [originToken, setUserInput] - ); - - const displayValue = useMemo(() => { - if (isUserTyping) { - return inputBuffer; - } - - if (!isUserInput && isUpdateLoading) { - return ""; - } - - const amount = isUserInput - ? quoteRequest.userInputAmount - : quoteRequest.quoteOutputAmount; - - if (!amount || !originToken) { - return ""; - } - - return formatAmountForDisplay(amount, originToken, unit); - }, [ - isUserTyping, - inputBuffer, - isUserInput, - isUpdateLoading, - quoteRequest.userInputAmount, - quoteRequest.quoteOutputAmount, - originToken, - unit, - ]); - - const [convertedAmount, setConvertedAmount] = useState(); - - useEffect(() => { - const amount = isUserInput - ? quoteRequest.userInputAmount - : quoteRequest.quoteOutputAmount; - - if (!originToken || !amount) { - setConvertedAmount(undefined); - return; - } - - try { - const formatted = formatAmountForDisplay(amount, originToken, unit); - if (unit === "token") { - const usdValue = convertTokenToUSD(formatted, originToken); - setConvertedAmount(usdValue); - } else { - const tokenValue = convertUSDToToken(formatted, originToken); - setConvertedAmount(tokenValue); - } - } catch (e) { - setConvertedAmount(undefined); - } - }, [ - originToken, - quoteRequest.userInputAmount, - quoteRequest.quoteOutputAmount, - unit, - isUserInput, - ]); - - const toggleUnit = useCallback(() => { - if (!originToken || !convertedAmount) { - setUnit(unit === "token" ? "usd" : "token"); - return; - } - - const newUnit = unit === "token" ? "usd" : "token"; - setUnit(newUnit); - - if (isUserInput && quoteRequest.userInputAmount) { - setUserInput("origin", convertedAmount); - } - - setIsUserTyping(false); - setInputBuffer(""); - }, [ + const { + displayValue, + formattedConvertedAmount, + inputDisabled, + handleInputChange, + handleBalanceClick, + toggleUnit, + } = useTokenAmountInput({ + token: originToken, + fieldName: "origin", unit, - originToken, - convertedAmount, - isUserInput, - quoteRequest.userInputAmount, setUnit, + isUpdateLoading, setUserInput, - ]); - - const handleInputChange = useCallback( - (value: string) => { - if (!isValidNumberInput(value)) { - return; - } - - setIsUserTyping(true); - setInputBuffer(value); - handleSetInputValue(value); - }, - [handleSetInputValue] - ); - - const handleBlur = useCallback(() => { - setIsUserTyping(false); - setInputBuffer(""); - }, []); - - const inputDisabled = (() => { - if (!quoteRequest.destinationToken) return true; - return Boolean(!isUserInput && isUpdateLoading); - })(); + quoteRequest, + }); const balance = useTokenBalance(quoteRequest?.originToken); @@ -203,15 +69,6 @@ export const OriginTokenInput = ({ } }, [inputDisabled]); - const formattedConvertedAmount = useMemo(() => { - if (unit === "token") { - if (!convertedAmount) return "$0.00"; - return "$" + formatUSD(convertedAmount); - } - if (!convertedAmount) return "0.00"; - return `${formatUnits(convertedAmount, originToken?.decimals)} ${originToken?.symbol}`; - }, [unit, convertedAmount, originToken]); - return ( @@ -230,7 +87,6 @@ export const OriginTokenInput = ({ placeholder="0.00" value={displayValue} onChange={(e) => handleInputChange(e.target.value)} - onBlur={handleBlur} disabled={inputDisabled} error={insufficientBalance} /> diff --git a/src/views/SwapAndBridge/hooks/index.ts b/src/views/SwapAndBridge/hooks/index.ts new file mode 100644 index 000000000..2a36a9c17 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useTokenAmountInput"; diff --git a/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts b/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts new file mode 100644 index 000000000..9dfbc866b --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BigNumber } from "ethers"; +import { formatUnits } from "ethers/lib/utils"; +import { + convertTokenToUSD, + convertUSDToToken, + formatAmountForDisplay, + formatUSD, + isValidNumberInput, + parseInputValue, +} from "utils"; +import { UnitType } from "../components/TokenInput/OriginTokenInput"; +import { QuoteRequest } from "./useQuoteRequest/quoteRequestAction"; +import { EnrichedToken } from "../components/ChainTokenSelector/ChainTokenSelectorModal"; + +interface UseTokenAmountInputParams { + token: EnrichedToken | null; + fieldName: "origin" | "destination"; + unit: UnitType; + setUnit: (unit: UnitType) => void; + isUpdateLoading: boolean; + setUserInput: ( + field: "origin" | "destination", + amount: BigNumber | null + ) => void; + quoteRequest: QuoteRequest; +} + +interface UseTokenAmountInputReturn { + displayValue: string; + convertedAmount: BigNumber | undefined; + formattedConvertedAmount: string; + inputDisabled: boolean; + handleInputChange: (value: string) => void; + handleBalanceClick: (amount: BigNumber) => void; + toggleUnit: () => void; + isUserInput: boolean; +} + +export const useTokenAmountInput = ({ + token, + fieldName, + unit, + setUnit, + isUpdateLoading, + setUserInput, + quoteRequest, +}: UseTokenAmountInputParams): UseTokenAmountInputReturn => { + const isUserInput = quoteRequest.userInputField === fieldName; + + const handleSetInputValue = useCallback( + (value: string) => { + if (!token) { + setUserInput(fieldName, null); + return; + } + + try { + const parsed = parseInputValue(value, token, unit); + setUserInput(fieldName, parsed); + } catch (e) { + setUserInput(fieldName, null); + } + }, + [setUserInput, token, unit, fieldName] + ); + + const handleBalanceClick = useCallback( + (amount: BigNumber) => { + if (!token) return; + setUserInput(fieldName, amount); + }, + [token, setUserInput, fieldName] + ); + + const displayValue = useMemo(() => { + if (!isUserInput && isUpdateLoading) { + return ""; + } + + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!amount || !token) { + return ""; + } + + return formatAmountForDisplay(amount, token, unit); + }, [ + isUserInput, + isUpdateLoading, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + token, + unit, + ]); + + const [convertedAmount, setConvertedAmount] = useState(); + + useEffect(() => { + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!token || !amount) { + setConvertedAmount(undefined); + return; + } + + try { + const formatted = formatAmountForDisplay(amount, token, unit); + if (unit === "token") { + const usdValue = convertTokenToUSD(formatted, token); + setConvertedAmount(usdValue); + } else { + const tokenValue = convertUSDToToken(formatted, token); + setConvertedAmount(tokenValue); + } + } catch (e) { + setConvertedAmount(undefined); + } + }, [ + token, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + unit, + isUserInput, + ]); + + const toggleUnit = useCallback(() => { + if (!token || !convertedAmount) { + setUnit(unit === "token" ? "usd" : "token"); + return; + } + + const newUnit = unit === "token" ? "usd" : "token"; + setUnit(newUnit); + + if (isUserInput && quoteRequest.userInputAmount) { + setUserInput(fieldName, convertedAmount); + } + }, [ + unit, + token, + convertedAmount, + isUserInput, + quoteRequest.userInputAmount, + setUnit, + setUserInput, + fieldName, + ]); + + const handleInputChange = useCallback( + (value: string) => { + if (!isValidNumberInput(value)) { + return; + } + handleSetInputValue(value); + }, + [handleSetInputValue] + ); + + const inputDisabled = (() => { + if (!quoteRequest.destinationToken) return true; + return Boolean(!isUserInput && isUpdateLoading); + })(); + + const formattedConvertedAmount = useMemo(() => { + if (unit === "token") { + if (!convertedAmount) return "$0.00"; + return "$" + formatUSD(convertedAmount); + } + if (!convertedAmount) return "0.00"; + return `${formatUnits(convertedAmount, token?.decimals)} ${token?.symbol}`; + }, [unit, convertedAmount, token]); + + return { + displayValue, + convertedAmount, + formattedConvertedAmount, + inputDisabled, + handleInputChange, + handleBalanceClick, + toggleUnit, + isUserInput, + }; +}; diff --git a/yarn.lock b/yarn.lock index 7ae17dec6..0316d159d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23278,6 +23278,11 @@ react-modal@^3.12.1: react-lifecycles-compat "^3.0.0" warning "^4.0.3" +react-number-format@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.4.4.tgz#d31f0e260609431500c8d3f81bbd3ae1fb7cacad" + integrity sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA== + react-pro-sidebar@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-pro-sidebar/-/react-pro-sidebar-1.1.0.tgz#e8f4ca0d7c4ff9fd2c38f8a0b85664083173b7ac" From 1a82bce319c11c8193060c333c19e4affb48bbe9 Mon Sep 17 00:00:00 2001 From: jorgen Date: Tue, 16 Dec 2025 13:55:07 +0100 Subject: [PATCH 13/13] add tests and clean up --- src/utils/format.ts | 108 +++++++-------------------------- src/utils/tests/format.test.ts | 32 +++++++++- 2 files changed, 53 insertions(+), 87 deletions(-) diff --git a/src/utils/format.ts b/src/utils/format.ts index 95362e5d3..9b6b7856a 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -50,35 +50,6 @@ export function shortenString( )}`; } -/** - * Shortens an arbitrary string to a fixed number of characters, mindful of the character length of the delimiter - * @param str The string to be potentially shortened - * @param delimiter The delimiter - * @param maxChars The number of characters to constrain this string - * @returns `str` if string is less than maxChars. The first `maxChars` chars if the delimiter is too large. A collapsed version with the delimiter in the middle. - */ -export function shortenStringToLength( - str: string, - delimiter: string, - maxChars: number -) { - if (str.length <= maxChars) { - return str; - } else { - const charsNeeded = maxChars - delimiter.length; - // Delimiter is out of bounds - if (charsNeeded <= 0) { - return str.slice(0, maxChars); - } else { - const charDivision = charsNeeded / 2; - const left = str.slice(0, Math.ceil(charDivision)); - const right = - charDivision < 1 ? "" : str.slice(-Math.floor(charDivision)); - return `${left}${delimiter}${right}`; - } - } -} - export function shortenTransactionHash(hash: string): string { return `${hash.substring(0, 5)}...`; } @@ -128,21 +99,10 @@ export function formatUnitsWithMaxFractionsFnBuilder(decimals: number) { return closure; } -export function formatEtherRaw(wei: ethers.BigNumberish): string { - return ethers.utils.formatUnits(wei, 18); -} - export function parseUnits(value: string, decimals: number): ethers.BigNumber { return ethers.utils.parseUnits(value, decimals); } -export function parseUnitsFnBuilder(decimals: number) { - function closure(value: string) { - return parseUnits(value, decimals); - } - return closure; -} - export function parseEtherLike(value: string): ethers.BigNumber { return parseUnits(value, 18); } @@ -182,10 +142,6 @@ export function formattedBigNumberToNumber( } } -export function stringToHex(value: string) { - return ethers.utils.hexlify(ethers.utils.toUtf8Bytes(value)); -} - // appends hex tag to data export function tagHex( dataHex: string, @@ -196,11 +152,6 @@ export function tagHex( return ethers.utils.hexConcat([dataHex, delimitterHex, tagHex]); } -// converts a string tag to hex and appends, currently not in use -export function tagString(dataHex: string, tagString: string) { - return tagHex(dataHex, stringToHex(tagString)); -} - // tags only an address export function tagAddress( dataHex: string, @@ -227,13 +178,6 @@ export function convertToCapitalCase(str: string) { .join(" "); } -const twoSigFormatter = new Intl.NumberFormat("en-US", { - maximumSignificantDigits: 2, -}); - -export const formatNumberTwoSigDigits = - twoSigFormatter.format.bind(twoSigFormatter); - const threeMaxFracFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 3, }); @@ -242,13 +186,6 @@ export const formatNumberMaxFracDigits = threeMaxFracFormatter.format.bind( threeMaxFracFormatter ); -const twoMaxFracFormatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, -}); - -export const formatNumberTwoFracDigits = - twoMaxFracFormatter.format.bind(twoMaxFracFormatter); - export function formatMaxFracDigits(number: number, maxFracDigits: number) { const formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFracDigits, @@ -256,15 +193,6 @@ export function formatMaxFracDigits(number: number, maxFracDigits: number) { return formatter.format(number); } -export function formatPoolAPY( - wei: ethers.BigNumberish, - decimals: number -): string { - return formatNumberMaxFracDigits( - Number(ethers.utils.formatUnits(wei, decimals)) - ); -} - export function formatWeiPct(wei?: ethers.BigNumberish, precision: number = 3) { if (wei === undefined) { return undefined; @@ -275,20 +203,6 @@ export function formatWeiPct(wei?: ethers.BigNumberish, precision: number = 3) { }).format(Number(ethers.utils.formatEther(wei)) * 100); } -/** - * Formats a number into a human readable format - * @param num The number to format - * @returns A human readable format. I.e. 1000 -> 1K, 1001 -> 1K+ - */ -export function humanReadableNumber(num: number, decimals = 0): string { - if (num <= 0) return "0"; - return ( - numeral(num) - .format(decimals <= 0 ? "0a" : `0.${"0".repeat(decimals)}a`) - .toUpperCase() + "+" - ); -} - /** * Formats an 18 decimal WEI representation of USD into standard USD format * @param value A 18 decimal fixed-point integer representation of USD @@ -343,3 +257,25 @@ export function parseUnitsWithExtendedDecimals( } return ethers.utils.parseUnits(valueToParse, decimals); } + +export function formatNumberWithSeparators( + value: string, + maxDecimals: number = 18 +): string { + if (!value || value === ".") return value; + + const parts = value.split("."); + const integerPart = parts[0] || "0"; + const decimalPart = parts[1]; + + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + + if (parts.length === 2) { + const limitedDecimals = decimalPart.substring(0, maxDecimals); + return `${formattedInteger}.${limitedDecimals}`; + } else if (value.endsWith(".")) { + return `${formattedInteger}.`; + } + + return formattedInteger; +} diff --git a/src/utils/tests/format.test.ts b/src/utils/tests/format.test.ts index d9467348c..168862fbc 100644 --- a/src/utils/tests/format.test.ts +++ b/src/utils/tests/format.test.ts @@ -1,13 +1,15 @@ import { utils } from "ethers"; import { + formatNumberWithSeparators, + formatUSD, isValidString, shortenAddress, shortenString, shortenTransactionHash, smallNumberFormatter, - formatUSD, } from "../format"; + const VALID_ADDRESS = "0x04Fa0d235C4abf4BcF4787aF4CF447DE572eF828"; const TX_HASH = "0xe5a0c976ca4d09ce6ea034101e78b1a6a9536940cb8f246e7b54d1fe16b8c125"; @@ -72,3 +74,31 @@ describe("#formatUSD", () => { expect(formatUSD(utils.parseEther("100000.123456"))).toEqual("100,000.12"); }); }); + +describe("#formatNumberWithSeparators", () => { + it("adds thousand separators to integers", () => { + expect(formatNumberWithSeparators("1234567")).toEqual("1,234,567"); + }); + + it("preserves decimals while adding separators", () => { + expect(formatNumberWithSeparators("1234567.89")).toEqual("1,234,567.89"); + }); + + it("handles numbers under 1000", () => { + expect(formatNumberWithSeparators("999")).toEqual("999"); + expect(formatNumberWithSeparators("42.5")).toEqual("42.5"); + }); + + it("preserves trailing decimal point during input", () => { + expect(formatNumberWithSeparators("1234.")).toEqual("1,234."); + }); + + it("truncates decimals to maxDecimals", () => { + expect(formatNumberWithSeparators("1.123456789", 4)).toEqual("1.1234"); + }); + + it("returns empty/special values unchanged", () => { + expect(formatNumberWithSeparators("")).toEqual(""); + expect(formatNumberWithSeparators(".")).toEqual("."); + }); +});