diff --git a/apps/root/src/common/components/token-amount-input/index.tsx b/apps/root/src/common/components/token-amount-input/index.tsx deleted file mode 100644 index da0fdbca8..000000000 --- a/apps/root/src/common/components/token-amount-input/index.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import styled from 'styled-components'; -import React from 'react'; -import isUndefined from 'lodash/isUndefined'; -import { - Typography, - FormHelperText, - Button, - ContainerBox, - TokenPickerButton, - colors, - EmptyWalletIcon, - Skeleton, - FormControl, - InputContainer, - Input, -} from 'ui-library'; -import { FormattedMessage, useIntl } from 'react-intl'; -import { amountValidator, emptyTokenWithAddress, formatCurrencyAmount, formatUsdAmount } from '@common/utils/currency'; - -import { AmountsOfToken, Token } from '@types'; -import { getMaxDeduction, getMinAmountForMaxDeduction } from '@constants'; -import { formatUnits } from 'viem'; -import { PROTOCOL_TOKEN_ADDRESS } from '@common/mocks/tokens'; -import TokenIcon from '../token-icon'; -import { useThemeMode } from '@state/config/hooks'; -import { buildTypographyVariant } from 'ui-library/src/theme/typography'; - -const StyledInputContainer = styled(InputContainer)` - ${({ - theme: { - spacing, - palette: { mode }, - }, - }) => ` - padding: ${spacing(6)}; - border: 1px solid ${colors[mode].border.border1}; - `} -`; - -const StyledMaxButtonContainer = styled(ContainerBox)` - position: absolute; - - ${({ theme: { spacing } }) => ` - right: ${spacing(5)}; - top: ${spacing(3)}; - `} -`; - -const StyledInput = styled(Input)` - padding: 0 !important; - background-color: transparent !important; -`; - -type TokenAmountInputProps = { - id: string; - label: React.ReactNode; - cantFund?: boolean; - balance?: AmountsOfToken; - tokenAmount: AmountsOfToken; - isLoadingRoute?: boolean; - isLoadingBalance?: boolean; - selectedToken?: Token; - startSelectingCoin: (newToken: Token) => void; - onSetTokenAmount: (newAmount: string) => void; - maxBalanceBtn?: boolean; - priceImpact?: string; -}; - -const TokenAmountInput = ({ - id, - label, - cantFund, - balance, - tokenAmount, - isLoadingRoute, - isLoadingBalance, - selectedToken, - onSetTokenAmount, - startSelectingCoin, - maxBalanceBtn, - priceImpact, -}: TokenAmountInputProps) => { - const mode = useThemeMode(); - const [isFocused, setIsFocused] = React.useState(false); - const intl = useIntl(); - const onSetMaxBalance = () => { - if (balance && selectedToken) { - if (selectedToken.address === PROTOCOL_TOKEN_ADDRESS) { - const maxValue = - BigInt(balance.amount) >= getMinAmountForMaxDeduction(selectedToken.chainId) - ? BigInt(balance.amount) - getMaxDeduction(selectedToken.chainId) - : BigInt(balance.amount); - onSetTokenAmount(formatUnits(maxValue, selectedToken.decimals)); - } else { - onSetTokenAmount(formatUnits(BigInt(balance.amount), selectedToken.decimals)); - } - } - }; - - const token = - (selectedToken && { - ...selectedToken, - icon: , - }) || - undefined; - - return ( - - - - - {label} - - startSelectingCoin(selectedToken || emptyTokenWithAddress('token'))} - /> - {!isUndefined(balance) && token && ( - - - - - - {isLoadingBalance ? ( - - ) : ( - <> - {formatCurrencyAmount({ amount: balance.amount, token, intl })} - {balance.amountInUSD && ` / ≈$${formatUsdAmount({ amount: balance.amountInUSD, intl })}`} - - )} - - - )} - - - - - amountValidator({ - onChange: onSetTokenAmount, - nextValue: evt.target.value, - decimals: token?.decimals || 18, - }) - } - disabled={isLoadingRoute} - value={tokenAmount.amountInUnits} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - autoComplete="off" - placeholder="0.0" - disableUnderline - inputProps={{ - style: { - textAlign: 'right', - height: 'auto', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - color: colors[mode].typography.typo2, - WebkitTextFillColor: 'unset', - }, - }} - sx={{ - ...buildTypographyVariant(mode).h2Bold, - color: 'inherit', - textAlign: 'right', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - }} - /> - - - {` $${formatUsdAmount({ amount: tokenAmount.amountInUSD || 0, intl }) || '0.00'}`} - {priceImpact && - !isNaN(Number(priceImpact)) && - isFinite(Number(priceImpact)) && - tokenAmount.amountInUnits !== '...' && ( - 0 - ? colors[mode].semantic.success.darker - : 'inherit' - } - > - {` `}({Number(priceImpact) > 0 ? '+' : ''} - {priceImpact}%) - - )} - - - {maxBalanceBtn && !isUndefined(balance) && selectedToken && ( - - - - )} - - {!!cantFund && ( - - - - )} - - ); -}; - -export default TokenAmountInput; diff --git a/apps/root/src/pages/aggregator/swap-container/components/step1/index.tsx b/apps/root/src/pages/aggregator/swap-container/components/step1/index.tsx index 3dd5cc64a..9b7c735a7 100644 --- a/apps/root/src/pages/aggregator/swap-container/components/step1/index.tsx +++ b/apps/root/src/pages/aggregator/swap-container/components/step1/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Grid, Alert, Button, ContainerBox, Typography, colors } from 'ui-library'; +import { Grid, Alert, Button, ContainerBox, Typography, colors, TokenPickerAmountUsdInput } from 'ui-library'; import isUndefined from 'lodash/isUndefined'; import { AmountsOfToken, SetStateCallback, SwapOption, Token } from '@types'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; @@ -13,17 +13,17 @@ import QuoteData from '../quote-data'; import TransferTo from '../transfer-to'; import QuoteSimulation from '../quote-simulation'; import AdvancedSettings from '../advanced-settings'; -import TokenPickerWithAmount from '@common/components/token-amount-input'; import ToggleButton from '../toggle-button'; import QuoteSelection from '../quote-selection'; import SwapNetworkSelector from '../swap-network-selector'; import SwapButton from '../swap-button'; -import { usePortfolioPrices } from '@state/balances/hooks'; -import { compact } from 'lodash'; -import { parseNumberUsdPriceToBigInt, parseUsdPrice } from '@common/utils/currency'; +import { emptyTokenWithAddress, parseNumberUsdPriceToBigInt, parseUsdPrice } from '@common/utils/currency'; import { ContactListActiveModal } from '@common/components/contact-modal'; import FormWalletSelector from '@common/components/form-wallet-selector'; - +import TokenIcon from '@common/components/token-icon'; +import useRawUsdPrice from '@hooks/useUsdRawPrice'; +import { usePortfolioPrices } from '@state/balances/hooks'; +import { compact } from 'lodash'; interface SwapFirstStepProps { from: Token | null; fromValue: string; @@ -87,7 +87,9 @@ const SwapFirstStep = ({ const dispatch = useAppDispatch(); const { trackEvent } = useAnalytics(); const [transactionWillFail, setTransactionWillFail] = React.useState(false); - const prices = usePortfolioPrices(compact([from, to])); + const prices = usePortfolioPrices(compact([from])); + const [toPrice] = useRawUsdPrice(to); + const fromPrice = from ? parseNumberUsdPriceToBigInt(prices[from?.address]?.price) : undefined; let fromValueToUse = isBuyOrder && selectedRoute @@ -107,24 +109,16 @@ const SwapFirstStep = ({ (fromValueToUse && fromValueToUse !== '' && from && - prices[from?.address] && - parseUsdPrice( - from, - parseUnits(fromValueToUse, from.decimals), - parseNumberUsdPriceToBigInt(prices[from.address].price) - )) || + fromPrice && + parseUsdPrice(from, parseUnits(fromValueToUse, from.decimals), fromPrice)) || undefined; const toUsdValueToUse = selectedRoute?.buyAmount.amountInUSD || (toValueToUse && toValueToUse !== '' && to && - prices[to?.address] && - parseUsdPrice( - to, - parseUnits(toValueToUse, to.decimals), - parseNumberUsdPriceToBigInt(prices[to.address].price) - )) || + toPrice && + parseUsdPrice(to, parseUnits(toValueToUse, to.decimals), toPrice)) || undefined; const selectedNetwork = useSelectedNetwork(); @@ -177,6 +171,15 @@ const SwapFirstStep = ({ ).toFixed(2)) || undefined; + const fromTokenWithIcon = React.useMemo( + () => (from ? { ...from, icon: } : undefined), + [from] + ); + const toTokenWithIcon = React.useMemo( + () => (to ? { ...to, icon: } : undefined), + [to] + ); + return ( @@ -207,33 +210,35 @@ const SwapFirstStep = ({ - } cantFund={cantFund} - tokenAmount={fromAmount} - isLoadingRoute={isLoadingRoute} + value={fromValueToUse} + disabled={isLoadingRoute} isLoadingBalance={isLoadingFromBalance} - startSelectingCoin={(token) => startSelectingCoin(token, 'from')} - selectedToken={from || undefined} - onSetTokenAmount={onSetFromAmount} + startSelectingCoin={(token) => startSelectingCoin(token || emptyTokenWithAddress('from'), 'from')} + token={fromTokenWithIcon} + onChange={onSetFromAmount} balance={balanceFrom} maxBalanceBtn + tokenPrice={fromPrice} /> - } - tokenAmount={toAmount} - isLoadingRoute={isLoadingRoute} + value={toValueToUse} + disabled={isLoadingRoute} isLoadingBalance={isLoadingToBalance} - startSelectingCoin={(token) => startSelectingCoin(token, 'to')} + startSelectingCoin={(token) => startSelectingCoin(token || emptyTokenWithAddress('to'), 'to')} balance={balanceTo} - selectedToken={to || undefined} - onSetTokenAmount={onSetToAmount} + token={toTokenWithIcon} + onChange={onSetToAmount} priceImpact={priceImpact} + tokenPrice={toPrice} /> diff --git a/package.json b/package.json index 38ee222b5..3112ecf97 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "downloadAndBuildTranslations": "yarn downloadTranslations:auth && yarn compile" }, "dependencies": { - "@balmy/sdk": "0.7.14" + "@balmy/sdk": "0.8.0" }, "devDependencies": { "@formatjs/cli": "^6.0.4", diff --git a/packages/ui-library/src/components/index.tsx b/packages/ui-library/src/components/index.tsx index 304410aae..d1e38e45c 100644 --- a/packages/ui-library/src/components/index.tsx +++ b/packages/ui-library/src/components/index.tsx @@ -79,6 +79,7 @@ export * from './background-paper'; export * from './select'; export * from './container-box'; export * from './token-amount-usd-input'; +export * from './token-picker-amount-usd-input'; export * from './token-picker-button'; export * from './token-picker'; export * from './options-buttons'; diff --git a/packages/ui-library/src/components/token-amount-usd-input/TokenAmountUsdInput.stories.tsx b/packages/ui-library/src/components/token-amount-usd-input/TokenAmountUsdInput.stories.tsx index ab348b2b0..6162bae4a 100644 --- a/packages/ui-library/src/components/token-amount-usd-input/TokenAmountUsdInput.stories.tsx +++ b/packages/ui-library/src/components/token-amount-usd-input/TokenAmountUsdInput.stories.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { PROTOCOL_TOKEN_ADDRESS, TokenAmounUsdInput } from '.'; +import { TokenAmounUsdInput } from '.'; +import { PROTOCOL_TOKEN_ADDRESS } from './useTokenAmountUsd'; import type { TokenAmounUsdInputProps } from '.'; import { TokenType } from 'common-types'; diff --git a/packages/ui-library/src/components/token-amount-usd-input/index.tsx b/packages/ui-library/src/components/token-amount-usd-input/index.tsx index 734d10a55..9425d6c0a 100644 --- a/packages/ui-library/src/components/token-amount-usd-input/index.tsx +++ b/packages/ui-library/src/components/token-amount-usd-input/index.tsx @@ -1,78 +1,24 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useRef } from 'react'; import { Button, ContainerBox, FormControl, IconButton, Typography, InputContainer } from '..'; import isUndefined from 'lodash/isUndefined'; -import styled, { DefaultTheme, ThemeProps } from 'styled-components'; +import styled from 'styled-components'; import Input from '@mui/material/Input'; import { ToggleArrowIcon } from '../../icons'; import { colors } from '../../theme'; import { buildTypographyVariant } from '../../theme/typography'; import { AmountsOfToken, Token } from 'common-types'; -import { FormattedMessage, useIntl } from 'react-intl'; -import { Address, formatUnits, parseUnits } from 'viem'; +import { FormattedMessage } from 'react-intl'; import { useTheme } from '@mui/material'; import { formatCurrencyAmount } from '../../common/utils/currency'; import { withStyles } from 'tss-react/mui'; -import { Chains } from '@balmy/sdk'; - -// TODO: BLY-3260 Move to common packagez -export const MIN_AMOUNT_FOR_MAX_DEDUCTION = { - [Chains.POLYGON.chainId]: parseUnits('0.1', 18), - [Chains.BNB_CHAIN.chainId]: parseUnits('0.1', 18), - [Chains.ARBITRUM.chainId]: parseUnits('0.001', 18), - [Chains.OPTIMISM.chainId]: parseUnits('0.001', 18), - [Chains.ETHEREUM.chainId]: parseUnits('0.1', 18), - [Chains.BASE_GOERLI.chainId]: parseUnits('0.1', 18), - [Chains.GNOSIS.chainId]: parseUnits('0.1', 18), - [Chains.MOONBEAM.chainId]: parseUnits('0.1', 18), -}; - -export const MAX_DEDUCTION = { - [Chains.POLYGON.chainId]: parseUnits('0.045', 18), - [Chains.BNB_CHAIN.chainId]: parseUnits('0.045', 18), - [Chains.ARBITRUM.chainId]: parseUnits('0.00015', 18), - [Chains.OPTIMISM.chainId]: parseUnits('0.000525', 18), - [Chains.ETHEREUM.chainId]: parseUnits('0.021', 18), - [Chains.BASE_GOERLI.chainId]: parseUnits('0.021', 18), - [Chains.GNOSIS.chainId]: parseUnits('0.1', 18), - [Chains.MOONBEAM.chainId]: parseUnits('0.1', 18), -}; - -export const getMinAmountForMaxDeduction = (chainId: number) => - MIN_AMOUNT_FOR_MAX_DEDUCTION[chainId] || parseUnits('0.1', 18); -export const getMaxDeduction = (chainId: number) => MAX_DEDUCTION[chainId] || parseUnits('0.045', 18); -export const PROTOCOL_TOKEN_ADDRESS: Address = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; - -const getInputColor = ({ - disabled, - hasValue, - mode, -}: { - disabled?: boolean; - hasValue?: boolean; - mode: ThemeProps['theme']['palette']['mode']; -}) => { - if (disabled) { - return colors[mode].typography.typo2; - } else if (hasValue) { - return colors[mode].typography.typo3; - } else { - return colors[mode].typography.typo5; - } -}; - -const getSubInputColor = ({ - hasValue, - mode, -}: { - hasValue?: boolean; - mode: ThemeProps['theme']['palette']['mode']; -}) => { - if (hasValue) { - return colors[mode].typography.typo3; - } else { - return colors[mode].typography.typo5; - } -}; +import useTokenAmountUsd, { + calculateTokenAmount, + calculateUsdAmount, + getInputColor, + getSubInputColor, + handleAmountValidator, + InputTypeT, +} from './useTokenAmountUsd'; const StyledButton = styled(Button)` min-width: 0; @@ -108,60 +54,33 @@ const StyledIconButton = withStyles(IconButton, ({ palette }) => ({ }, })); -const calculateUsdAmount = ({ - value, - token, - tokenPrice, -}: { - value?: string; - token?: Nullable; - tokenPrice?: bigint; -}) => - isUndefined(value) || value === '' || isUndefined(tokenPrice) || !token - ? '0' - : parseFloat(formatUnits(parseUnits(value, token.decimals) * tokenPrice, token.decimals + 18)).toFixed(2); - -const calculateTokenAmount = ({ value, tokenPrice }: { value?: string; tokenPrice?: bigint }) => - isUndefined(value) || value === '' || isUndefined(tokenPrice) - ? '0' - : formatUnits(parseUnits(value, 18 * 2) / tokenPrice, 18).toString(); - -const validator = ({ - nextValue, - decimals, - onChange, -}: { - nextValue: string; - onChange: (newValue: string) => void; - decimals: number; -}) => { - const newNextValue = nextValue.replace(/,/g, '.'); - // sanitize value - const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d{0,${decimals}}$`); - - if (inputRegex.test(newNextValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) { - onChange(newNextValue.startsWith('.') ? `0${newNextValue}` : newNextValue || ''); - } -}; - const TokenInput = ({ onChange, value, token, tokenPrice, onBlur, onFocus, disabled }: InputProps) => { const { palette: { mode }, } = useTheme(); const usdAmount = calculateUsdAmount({ value, token, tokenPrice }); + const inputRef = useRef(null); + + const handleChange = useCallback( + (evt: React.ChangeEvent) => { + handleAmountValidator({ + onChange, + nextValue: evt.target.value, + decimals: token?.decimals || 18, + currentValue: value, + inputRef, + }); + }, + [onChange, value, inputRef, token?.decimals] + ); return ( - validator({ - onChange, - nextValue: evt.target.value, - decimals: token?.decimals || 18, - }) - } + onChange={handleChange} + inputRef={inputRef} value={value || ''} onFocus={onFocus} onBlur={onBlur} @@ -186,25 +105,32 @@ const TokenInput = ({ onChange, value, token, tokenPrice, onBlur, onFocus, disab ); }; -// TODO const UsdInput = ({ onChange, value, token, tokenPrice, onBlur, onFocus, disabled }: InputProps) => { const { palette: { mode }, } = useTheme(); const tokenAmount = calculateTokenAmount({ value, tokenPrice }); + const inputRef = useRef(null); + + const handleChange = useCallback( + (evt: React.ChangeEvent) => { + handleAmountValidator({ + onChange, + nextValue: evt.target.value, + decimals: 2, + currentValue: value, + inputRef, + }); + }, + [onChange, value, inputRef] + ); return ( - validator({ - onChange, - nextValue: evt.target.value, - decimals: 2, - }) - } + onChange={handleChange} value={value || ''} onFocus={onFocus} onBlur={onBlur} @@ -259,11 +185,6 @@ const InputContentContainer = styled(ContainerBox).attrs({ gap: 3 })` `} `; -enum InputTypeT { - usd = 'usd', - token = 'token', -} - const TokenAmounUsdInput = ({ token, balance, @@ -273,101 +194,18 @@ const TokenAmounUsdInput = ({ disabled, onMaxCallback, }: TokenAmounUsdInputProps) => { - const [internalValue, setInternalValue] = useState(value); const { - palette: { mode }, - } = useTheme(); - const [isFocused, setIsFocused] = useState(false); - const [inputType, setInputType] = useState(InputTypeT.token); - const intl = useIntl(); - - useEffect(() => { - // We basically check if by some reason or other, the value of the parent component has changed to something that we did not send - // But we only need to check for when the inputType is the token direct amount. - if (inputType === InputTypeT.token) { - if (value !== internalValue) { - setInternalValue(value); - } - } else if (inputType === InputTypeT.usd && !isUndefined(tokenPrice) && token) { - if (isUndefined(value)) { - setInternalValue(undefined); - return; - } - - const newInternalValue = calculateUsdAmount({ value, token, tokenPrice }); - - if (!internalValue || newInternalValue !== parseFloat(internalValue).toFixed(2)) { - setInternalValue(newInternalValue); - } - } else { - throw new Error('invalid inputType'); - } - }, [value]); - - const onChangeType = () => { - let newInternalValue: string | undefined; - - if (isUndefined(tokenPrice)) { - return; - } - - if (!isUndefined(value)) { - if (inputType === InputTypeT.token && token) { - newInternalValue = calculateUsdAmount({ value, token, tokenPrice }); - } else if (inputType === InputTypeT.usd) { - newInternalValue = calculateTokenAmount({ value: internalValue || '0', tokenPrice }); - } else { - throw new Error('invalid inputType'); - } - } - - setInputType((oldInputType) => (oldInputType === InputTypeT.token ? InputTypeT.usd : InputTypeT.token)); - setInternalValue(newInternalValue); - }; - - const onValueChange = (newValue: string) => { - if (inputType === InputTypeT.token) { - onChange(newValue); - } else if (inputType === InputTypeT.usd) { - if (isUndefined(tokenPrice)) { - // Should never happen since we disable the button to change the inputType when there is no token price, never hurts to take into account - throw new Error('Token price is undefined for inputType usd'); - } - - setInternalValue(newValue); - - onChange( - calculateTokenAmount({ - value: newValue, - tokenPrice, - }) - ); - } else { - throw new Error('invalid inputType'); - } - }; - - const onMaxValueClick = () => { - if (!balance) { - throw new Error('should not call on max value without a balance'); - } - - if (onMaxCallback) { - // onChange will be called by the parent component - onMaxCallback(); - return; - } + isFocused, + setIsFocused, + mode, + inputType, + internalValue, + onChangeType, + onValueChange, + onMaxValueClick, + intl, + } = useTokenAmountUsd({ value, token, tokenPrice, onChange, onMaxCallback, balance }); - if (balance && token && token.address === PROTOCOL_TOKEN_ADDRESS) { - const maxValue = - BigInt(balance.amount) >= getMinAmountForMaxDeduction(token.chainId) - ? BigInt(balance.amount) - getMaxDeduction(token.chainId) - : BigInt(balance.amount); - onChange(formatUnits(maxValue, token.decimals)); - } else { - onChange(formatUnits(BigInt(balance.amount), token?.decimals || 18)); - } - }; return ( @@ -411,7 +249,10 @@ const TokenAmounUsdInput = ({ {balance && ( - + {` `} {formatCurrencyAmount({ amount: balance.amount, token: token || undefined, intl })} diff --git a/packages/ui-library/src/components/token-amount-usd-input/useTokenAmountUsd.ts b/packages/ui-library/src/components/token-amount-usd-input/useTokenAmountUsd.ts new file mode 100644 index 000000000..6f0dbc361 --- /dev/null +++ b/packages/ui-library/src/components/token-amount-usd-input/useTokenAmountUsd.ts @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { Chains } from '@balmy/sdk'; +import { Address, formatUnits, parseUnits } from 'viem'; +import { AmountsOfToken, Token } from 'common-types'; +import { ThemeProps, DefaultTheme, useTheme } from 'styled-components'; +import { useIntl } from 'react-intl'; +import isUndefined from 'lodash/isUndefined'; +import { colors } from '../../theme'; + +// TODO: BLY-3260 Move to common packagez +export const MIN_AMOUNT_FOR_MAX_DEDUCTION = { + [Chains.POLYGON.chainId]: parseUnits('0.1', 18), + [Chains.BNB_CHAIN.chainId]: parseUnits('0.1', 18), + [Chains.ARBITRUM.chainId]: parseUnits('0.001', 18), + [Chains.OPTIMISM.chainId]: parseUnits('0.001', 18), + [Chains.ETHEREUM.chainId]: parseUnits('0.1', 18), + [Chains.BASE_GOERLI.chainId]: parseUnits('0.1', 18), + [Chains.GNOSIS.chainId]: parseUnits('0.1', 18), + [Chains.MOONBEAM.chainId]: parseUnits('0.1', 18), +}; + +export const MAX_DEDUCTION = { + [Chains.POLYGON.chainId]: parseUnits('0.045', 18), + [Chains.BNB_CHAIN.chainId]: parseUnits('0.045', 18), + [Chains.ARBITRUM.chainId]: parseUnits('0.00015', 18), + [Chains.OPTIMISM.chainId]: parseUnits('0.000525', 18), + [Chains.ETHEREUM.chainId]: parseUnits('0.021', 18), + [Chains.BASE_GOERLI.chainId]: parseUnits('0.021', 18), + [Chains.GNOSIS.chainId]: parseUnits('0.1', 18), + [Chains.MOONBEAM.chainId]: parseUnits('0.1', 18), +}; + +export const getMinAmountForMaxDeduction = (chainId: number) => + MIN_AMOUNT_FOR_MAX_DEDUCTION[chainId] || parseUnits('0.1', 18); +export const getMaxDeduction = (chainId: number) => MAX_DEDUCTION[chainId] || parseUnits('0.045', 18); +export const PROTOCOL_TOKEN_ADDRESS: Address = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + +export const calculateUsdAmount = ({ + value, + token, + tokenPrice, +}: { + value?: string; + token?: Nullable; + tokenPrice?: bigint; +}) => + isUndefined(value) || value === '' || isUndefined(tokenPrice) || !token + ? '0' + : parseFloat(formatUnits(parseUnits(value, token.decimals) * tokenPrice, token.decimals + 18)).toFixed(2); + +export const calculateTokenAmount = ({ value, tokenPrice }: { value?: string; tokenPrice?: bigint }) => + isUndefined(value) || value === '' || isUndefined(tokenPrice) || tokenPrice === BigInt(0) + ? '0' + : formatUnits(parseUnits(value, 18 * 2) / tokenPrice, 18).toString(); + +const amountValidator = ({ + nextValue, + decimals, + onChange, +}: { + nextValue: string; + onChange: (newValue: string) => void; + decimals: number; +}) => { + const newNextValue = nextValue.replace(/,/g, '.'); + + const inputRegex = RegExp(`^\\d*(?:[.])?\\d*$`); // allow ANY decimals temporarily + + if (inputRegex.test(newNextValue)) { + const [integer, decimal] = newNextValue.split('.'); + + if (decimal && decimal.length > decimals) { + // too many decimals => cut the excess + const trimmedValue = `${integer}.${decimal.slice(0, decimals)}`; + onChange(trimmedValue.startsWith('.') ? `0${trimmedValue}` : trimmedValue); + return; + } + + onChange(newNextValue.startsWith('.') ? `0${newNextValue}` : newNextValue); + } +}; + +export const handleAmountValidator = ({ + nextValue, + decimals, + onChange, + currentValue, + inputRef, +}: { + nextValue: string; + decimals: number; + onChange: (newValue: string) => void; + currentValue?: string; + inputRef: React.RefObject; +}) => { + const input = inputRef.current; + const newCursorPos = input?.selectionStart ?? 0; + // Store the previous value to check if the input is empty. + const prevValue = currentValue; + + amountValidator({ + onChange, + nextValue, + decimals, + }); + + // As decimals are replaced, the cursor position is set to the end of the input. + // With requestAnimationFrame, we ensure the cursor position is updated after the input is updated. + requestAnimationFrame(() => { + if (input && prevValue !== '') { + input.setSelectionRange(newCursorPos, newCursorPos); + } + }); +}; + +export const getInputColor = ({ + disabled, + hasValue, + mode, +}: { + disabled?: boolean; + hasValue?: boolean; + mode: ThemeProps['theme']['palette']['mode']; +}) => { + if (disabled) { + return colors[mode].typography.typo2; + } else if (hasValue) { + return colors[mode].typography.typo1; + } else { + return colors[mode].typography.typo5; + } +}; + +export const getSubInputColor = ({ + hasValue, + mode, +}: { + hasValue?: boolean; + mode: ThemeProps['theme']['palette']['mode']; +}) => { + if (hasValue) { + return colors[mode].typography.typo3; + } else { + return colors[mode].typography.typo5; + } +}; + +export enum InputTypeT { + usd = 'usd', + token = 'token', +} + +interface UseTokenAmountUsdProps { + value?: string; + token?: Nullable; + tokenPrice?: bigint; + onChange: (newValue: string) => void; + onMaxCallback?: () => void; + balance?: AmountsOfToken; +} + +const useTokenAmountUsd = ({ value, token, tokenPrice, onChange, onMaxCallback, balance }: UseTokenAmountUsdProps) => { + const [internalValue, setInternalValue] = useState(value); + const { + palette: { mode }, + } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + const [inputType, setInputType] = useState(InputTypeT.token); + const intl = useIntl(); + + useEffect(() => { + // We basically check if by some reason or other, the value of the parent component has changed to something that we did not send + // But we only need to check for when the inputType is the token direct amount. + if (inputType === InputTypeT.token) { + if (value !== internalValue) { + setInternalValue(value); + } + } else if (inputType === InputTypeT.usd) { + if (isUndefined(value) || isUndefined(tokenPrice) || !token) { + setInternalValue(undefined); + return; + } + + const newInternalValue = calculateUsdAmount({ value, token, tokenPrice }); + if (isUndefined(internalValue) || newInternalValue !== parseFloat(internalValue || '0').toFixed(2)) { + setInternalValue(newInternalValue); + } + } else { + throw new Error('invalid inputType'); + } + }, [value]); + + useEffect(() => { + if (!token) { + setInputType(InputTypeT.token); + } + }, [token]); + + const onChangeType = () => { + let newInternalValue: string | undefined; + + if (isUndefined(tokenPrice) || !token) { + return; + } + + if (!isUndefined(value)) { + if (inputType === InputTypeT.token) { + newInternalValue = calculateUsdAmount({ value, token, tokenPrice }); + } else if (inputType === InputTypeT.usd) { + newInternalValue = calculateTokenAmount({ value: internalValue || '0', tokenPrice }); + } else { + throw new Error('invalid inputType'); + } + } + + setInputType((oldInputType) => (oldInputType === InputTypeT.token ? InputTypeT.usd : InputTypeT.token)); + setInternalValue(newInternalValue); + }; + + const onValueChange = (newValue: string) => { + if (inputType === InputTypeT.token) { + onChange(newValue); + } else if (inputType === InputTypeT.usd) { + if (isUndefined(tokenPrice)) { + // Should never happen since we disable the button to change the inputType when there is no token price, never hurts to take into account + throw new Error('Token price is undefined for inputType usd'); + } + + setInternalValue(newValue); + + onChange( + calculateTokenAmount({ + value: newValue, + tokenPrice, + }) + ); + } else { + throw new Error('invalid inputType'); + } + }; + + const onMaxValueClick = () => { + if (!balance) { + throw new Error('should not call on max value without a balance'); + } + + if (onMaxCallback) { + // onChange will be called by the parent component + onMaxCallback(); + return; + } + + if (balance && token && token.address === PROTOCOL_TOKEN_ADDRESS) { + const maxValue = + BigInt(balance.amount) >= getMinAmountForMaxDeduction(token.chainId) + ? BigInt(balance.amount) - getMaxDeduction(token.chainId) + : BigInt(balance.amount); + onChange(formatUnits(maxValue, token.decimals)); + } else { + onChange(formatUnits(BigInt(balance.amount), token?.decimals || 18)); + } + }; + + return useMemo( + () => ({ + intl, + isFocused, + setIsFocused, + mode, + inputType, + internalValue, + onChangeType, + onValueChange, + onMaxValueClick, + }), + [intl, isFocused, setIsFocused, mode, inputType, internalValue, onChangeType, onValueChange, onMaxValueClick] + ); +}; + +export default useTokenAmountUsd; diff --git a/packages/ui-library/src/components/token-picker-amount-usd-input/TokenPickerAmountUsdInput.stories.tsx b/packages/ui-library/src/components/token-picker-amount-usd-input/TokenPickerAmountUsdInput.stories.tsx new file mode 100644 index 000000000..46160b9a5 --- /dev/null +++ b/packages/ui-library/src/components/token-picker-amount-usd-input/TokenPickerAmountUsdInput.stories.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { TokenType } from 'common-types'; +import { TokenPickerAmountUsdInput, TokenPickerAmountUsdInputProps } from '.'; + +function StoryTokenAmountUsdInput({ ...args }: TokenPickerAmountUsdInputProps) { + const [value, setValue] = useState(args.value || ''); + + const onChange = (newValue: string) => { + setValue(newValue); + }; + + return ; +} + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + title: 'Components/TokenPickerAmountUsdInput', + component: StoryTokenAmountUsdInput, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + render: (args) => , + args: { + id: 'token-picker-amount-usd-input', + label: 'Token', + startSelectingCoin: () => {}, + onChange: () => {}, + token: { + name: 'Polygon Ecosystem Token', + symbol: 'POL', + address: '0xeeee', + chainId: 137, + icon: <>, + decimals: 18, + type: TokenType.BASE, + underlyingTokens: [], + chainAddresses: [], + }, + tokenPrice: BigInt('200000000000000000'), + balance: { + amount: BigInt('12100000000000000000'), + amountInUnits: '12.1', + amountInUSD: '17.03', + }, + maxBalanceBtn: true, + }, +}; +type Story = StoryObj; + +export const Empty: Story = { + args: { + value: '', + }, + render: (args: TokenPickerAmountUsdInputProps) => , +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: (args: TokenPickerAmountUsdInputProps) => , +}; + +export default meta; + +export { StoryTokenAmountUsdInput }; diff --git a/packages/ui-library/src/components/token-picker-amount-usd-input/index.tsx b/packages/ui-library/src/components/token-picker-amount-usd-input/index.tsx new file mode 100644 index 000000000..bd9746612 --- /dev/null +++ b/packages/ui-library/src/components/token-picker-amount-usd-input/index.tsx @@ -0,0 +1,437 @@ +import styled, { useTheme } from 'styled-components'; +import React, { useCallback, useRef } from 'react'; +import isUndefined from 'lodash/isUndefined'; +import { FormattedMessage } from 'react-intl'; + +import { buildTypographyVariant } from 'ui-library/src/theme/typography'; +import useTokenAmountUsd, { + calculateTokenAmount, + calculateUsdAmount, + getInputColor, + getSubInputColor, + handleAmountValidator, + InputTypeT, +} from '../token-amount-usd-input/useTokenAmountUsd'; +import { Token, AmountsOfToken, TokenWithIcon } from 'common-types'; +import { formatCurrencyAmount, formatUsdAmount } from '../../common/utils/currency'; +import { colors } from '../../theme'; +import { Button, FormControl, FormHelperText, IconButton, Input, Skeleton, Typography } from '@mui/material'; +import { ContainerBox } from '../container-box'; +import { TokenPickerButton } from '../token-picker-button'; +import { EmptyWalletIcon, ToggleArrowIcon } from '../../icons'; +import { InputContainer } from '../input-container'; + +const StyledInput = styled(Input)` + padding: 0 !important; + background-color: transparent !important; +`; + +interface InputProps { + id: string; + token?: Nullable; + tokenPrice?: bigint; + value?: string; + onChange: (newValue: string) => void; + disabled?: boolean; + onFocus: () => void; + onBlur: () => void; + priceImpactLabel?: React.ReactNode; + onChangeType: () => void; +} + +const TokenInput = ({ + id, + onChange, + value, + token, + tokenPrice, + onBlur, + onFocus, + disabled, + priceImpactLabel, + onChangeType, +}: InputProps) => { + const { + palette: { mode }, + } = useTheme(); + const usdAmount = calculateUsdAmount({ value, token, tokenPrice }); + const inputRef = useRef(null); + + const handleChange = useCallback( + (evt: React.ChangeEvent) => { + handleAmountValidator({ + onChange, + nextValue: evt.target.value, + decimals: token?.decimals || 18, + currentValue: value, + inputRef, + }); + }, + [onChange, value, inputRef, token?.decimals] + ); + + return ( + + + + + + + {`$${usdAmount}`} + + {priceImpactLabel} + + + + + + ); +}; + +/* + This is a custom input component that allows the user to input a USD amount. + The $ symbol is not part of the input value, but it is displayed to the user. + We need to handle the $ symbol manually to ensure the cursor is positioned correctly, for events like focus, click, and drag +*/ +const UsdInput = ({ + id, + onChange, + value, + token, + tokenPrice, + onBlur, + onFocus, + disabled, + priceImpactLabel, + onChangeType, +}: InputProps) => { + const { + palette: { mode }, + } = useTheme(); + const tokenAmount = calculateTokenAmount({ value, tokenPrice }); + const inputColor = getInputColor({ mode, hasValue: value !== '' && !isUndefined(value) }); + const inputRef = useRef(null); + + const handleChange = useCallback( + (evt: React.ChangeEvent) => { + // Remove $ character + const numericValue = evt.target.value.replace(/[$,]/g, ''); + + handleAmountValidator({ + onChange, + nextValue: numericValue, + decimals: 2, + currentValue: value, + inputRef, + }); + }, + [onChange, value, inputRef] + ); + + const handleKeyDown = useCallback( + (evt: React.KeyboardEvent) => { + // Prevent cursor from moving before the $ sign + if (evt.key === 'ArrowLeft' || evt.key === 'Backspace') { + if (inputRef.current) { + const cursorPos = inputRef.current.selectionStart; + if (cursorPos === 1) { + evt.preventDefault(); + } + } + } + + // Handle Home key to place cursor after $ sign + if (evt.key === 'Home') { + evt.preventDefault(); + if (inputRef.current) { + inputRef.current.setSelectionRange(1, 1); + } + } + }, + [inputRef] + ); + + const handleClick = useCallback(() => { + if (inputRef.current && inputRef.current.selectionStart === 0) { + // Move cursor to position 1 (after the $) + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(1, 1); + } + }, 0); + } + }, [inputRef]); + + const handleSelect = useCallback(() => { + if (inputRef.current) { + const { selectionStart, selectionEnd } = inputRef.current; + + // If selection starts at position 0, adjust it to start after the $ symbol + if (selectionStart === 0) { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(1, selectionEnd || 1); + } + }, 0); + } + } + }, [inputRef]); + + const handleFocus = useCallback(() => { + onFocus(); + setTimeout(() => { + if (inputRef.current && inputRef.current.selectionStart === 0) { + inputRef.current.setSelectionRange(1, 1); + } + }, 0); + }, [onFocus, inputRef]); + + return ( + + + + + + + ≈{` ${tokenAmount} ${token?.symbol}`} + + {priceImpactLabel} + + + + + + ); +}; + +const StyledInputContainer = styled(InputContainer)` + ${({ + theme: { + spacing, + palette: { mode }, + }, + disabled, + }) => ` + padding: ${spacing(6)}; + border: 1px solid ${colors[mode].border.border1}; + ${ + disabled && + ` + opacity: 0.8; + pointer-events: none; + ` + } + `} +`; + +const StyledMaxButtonContainer = styled(ContainerBox)` + position: absolute; + + ${({ theme: { spacing } }) => ` + right: ${spacing(3)}; + top: ${spacing(1)}; + `} +`; + +type TokenPickerAmountUsdInputProps = { + id: string; + label: React.ReactNode; + cantFund?: boolean; + balance?: AmountsOfToken; + value: string; + disabled?: boolean; + isLoadingBalance?: boolean; + token?: TokenWithIcon; + startSelectingCoin: (newToken?: Token) => void; + onChange: (newAmount: string) => void; + maxBalanceBtn?: boolean; + priceImpact?: string; + tokenPrice?: bigint; +}; + +const TokenPickerAmountUsdInput = ({ + id, + label, + cantFund, + balance, + value, + disabled, + isLoadingBalance, + token, + onChange, + startSelectingCoin, + maxBalanceBtn, + priceImpact, + tokenPrice, +}: TokenPickerAmountUsdInputProps) => { + const { + intl, + mode, + isFocused, + setIsFocused, + onMaxValueClick, + inputType, + internalValue, + onChangeType, + onValueChange, + } = useTokenAmountUsd({ + value, + token, + tokenPrice, + onChange, + balance, + }); + + const priceImpactLabel = priceImpact && + !isNaN(Number(priceImpact)) && + isFinite(Number(priceImpact)) && + value !== '...' && ( + 0 + ? colors[mode].semantic.success.darker + : 'inherit' + } + > + {` `}({Number(priceImpact) > 0 ? '+' : ''} + {priceImpact}%) + + ); + + return ( + + + + + {label} + + startSelectingCoin(token)} /> + {!isUndefined(balance) && token && ( + + + + + + {isLoadingBalance ? ( + + ) : ( + <> + {formatCurrencyAmount({ amount: balance.amount, token, intl })} + {balance.amountInUSD && ` ($${formatUsdAmount({ amount: balance.amountInUSD, intl })})`} + + )} + + + )} + + + + {inputType === InputTypeT.token ? ( + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + disabled={disabled} + priceImpactLabel={priceImpactLabel} + onChangeType={onChangeType} + /> + ) : ( + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + disabled={disabled} + priceImpactLabel={priceImpactLabel} + onChangeType={onChangeType} + /> + )} + + + {maxBalanceBtn && !isUndefined(balance) && token && ( + + + + )} + + {!!cantFund && ( + + + + )} + + ); +}; + +export { TokenPickerAmountUsdInput, TokenPickerAmountUsdInputProps }; diff --git a/packages/ui-library/src/components/transaction-receipt/TransactionReceipt.stories.tsx b/packages/ui-library/src/components/transaction-receipt/TransactionReceipt.stories.tsx index eceae1a2b..f5565fa1f 100644 --- a/packages/ui-library/src/components/transaction-receipt/TransactionReceipt.stories.tsx +++ b/packages/ui-library/src/components/transaction-receipt/TransactionReceipt.stories.tsx @@ -151,6 +151,8 @@ export const EarnWithdrawReceipt: Story = { }, data: { tokenFlow: TransactionEventIncomingTypes.INCOMING, + positionId: '1-0x1-1', + strategyId: '1-1-1', withdrawn: [ { token: { diff --git a/packages/ui-library/src/theme/variants/input-base-variants.ts b/packages/ui-library/src/theme/variants/input-base-variants.ts index 209ba72a8..95e3cccd0 100644 --- a/packages/ui-library/src/theme/variants/input-base-variants.ts +++ b/packages/ui-library/src/theme/variants/input-base-variants.ts @@ -95,4 +95,14 @@ export const buildInputBaseVariant = (mode: 'light' | 'dark'): Components => ({ }, }, }, + MuiInput: { + styleOverrides: { + input: { + '&::placeholder': { + color: colors[mode].typography.typo5, + opacity: 1, + }, + }, + }, + }, }); diff --git a/yarn.lock b/yarn.lock index 345c0607a..6fdac08d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1155,10 +1155,10 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" -"@balmy/sdk@*", "@balmy/sdk@0.7.14": - version "0.7.14" - resolved "https://registry.yarnpkg.com/@balmy/sdk/-/sdk-0.7.14.tgz#3ce4d96f5c2a6b581c91ae15cb83fb9df0d76d03" - integrity sha512-xSpTDRrFv9jbsC4MOZuj4M+c6TqgJJuljoiMwZtDnG61o1f6BzK+SK+5pWT3lRJz6NL7aQSxZW15DAE1ijO7tg== +"@balmy/sdk@*", "@balmy/sdk@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@balmy/sdk/-/sdk-0.8.0.tgz#53510cf1b9bc5c7c0c6d6f1a4cc01e0448ca0568" + integrity sha512-QAqqgQU6L29VxdQaNBynPnv3KRwPjsnU++WCbiYR/SFe0HkFAm+vNHW1dtMVcuMwrcpBLPKaALMjSDLIsMcjsw== dependencies: cross-fetch "3.1.5" crypto-js "4.2.0"