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"