Commit f95275d5 authored by Jordan Frankfurt's avatar Jordan Frankfurt Committed by GitHub

feat(widgets): Localize CurrencyAmounts and Prices (#3247)

* add basic number formatting

* test formatLocaleNumber

* localize CurrencyAmounts and Prices

* use lingui locale hook

* pr review

* cleaner type assertions

* check if locale is supported when formatting

* pr feedback
parent 0ec2dd41
...@@ -32,7 +32,7 @@ export const SUPPORTED_LOCALES = [ ...@@ -32,7 +32,7 @@ export const SUPPORTED_LOCALES = [
'vi-VN', 'vi-VN',
'zh-CN', 'zh-CN',
'zh-TW', 'zh-TW',
] as const ]
export type SupportedLocale = typeof SUPPORTED_LOCALES[number] | 'pseudo' export type SupportedLocale = typeof SUPPORTED_LOCALES[number] | 'pseudo'
// eslint-disable-next-line import/first // eslint-disable-next-line import/first
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useUSDCValue } from 'hooks/useUSDCPrice' import { useUSDCValue } from 'hooks/useUSDCPrice'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { loadingOpacityCss } from 'lib/css/loading' import { loadingOpacityCss } from 'lib/css/loading'
...@@ -34,6 +35,7 @@ interface InputProps { ...@@ -34,6 +35,7 @@ interface InputProps {
} }
export default function Input({ disabled }: InputProps) { export default function Input({ disabled }: InputProps) {
const { i18n } = useLingui()
const { const {
trade: { state: tradeState }, trade: { state: tradeState },
currencyBalances: { [Field.INPUT]: balance }, currencyBalances: { [Field.INPUT]: balance },
...@@ -85,7 +87,7 @@ export default function Input({ disabled }: InputProps) { ...@@ -85,7 +87,7 @@ export default function Input({ disabled }: InputProps) {
<LoadingSpan $loading={isLoading}>{inputUSDC ? `$${inputUSDC.toFixed(2)}` : '-'}</LoadingSpan> <LoadingSpan $loading={isLoading}>{inputUSDC ? `$${inputUSDC.toFixed(2)}` : '-'}</LoadingSpan>
{balance && ( {balance && (
<ThemedText.Body2 color={inputCurrencyAmount?.greaterThan(balance) ? 'error' : undefined}> <ThemedText.Body2 color={inputCurrencyAmount?.greaterThan(balance) ? 'error' : undefined}>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4)}</span> Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span>
</ThemedText.Body2> </ThemedText.Body2>
)} )}
</Row> </Row>
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useUSDCValue } from 'hooks/useUSDCPrice' import { useUSDCValue } from 'hooks/useUSDCPrice'
import { atom } from 'jotai' import { atom } from 'jotai'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
...@@ -45,6 +46,8 @@ interface OutputProps { ...@@ -45,6 +46,8 @@ interface OutputProps {
} }
export default function Output({ disabled, children }: OutputProps) { export default function Output({ disabled, children }: OutputProps) {
const { i18n } = useLingui()
const { const {
trade: { state: tradeState }, trade: { state: tradeState },
currencyBalances: { [Field.OUTPUT]: balance }, currencyBalances: { [Field.OUTPUT]: balance },
...@@ -106,7 +109,7 @@ export default function Output({ disabled, children }: OutputProps) { ...@@ -106,7 +109,7 @@ export default function Output({ disabled, children }: OutputProps) {
<LoadingSpan $loading={isLoading}>{usdc}</LoadingSpan> <LoadingSpan $loading={isLoading}>{usdc}</LoadingSpan>
{balance && ( {balance && (
<span> <span>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4)}</span> Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span>
</span> </span>
)} )}
</Row> </Row>
......
import { t } from '@lingui/macro' import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc' import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
...@@ -8,6 +9,7 @@ import { feeOptionsAtom } from 'lib/state/swap' ...@@ -8,6 +9,7 @@ import { feeOptionsAtom } from 'lib/state/swap'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { useMemo } from 'react' import { useMemo } from 'react'
import { currencyId } from 'utils/currencyId' import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedPriceImpact } from 'utils/prices'
import Row from '../../Row' import Row from '../../Row'
...@@ -48,47 +50,49 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) { ...@@ -48,47 +50,49 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
const integrator = window.location.hostname const integrator = window.location.hostname
const feeOptions = useAtomValue(feeOptionsAtom) const feeOptions = useAtomValue(feeOptionsAtom)
const { i18n } = useLingui()
const details = useMemo(() => { const details = useMemo(() => {
const rows = []
// @TODO(ianlapham): Check that provider fee is even a valid list item // @TODO(ianlapham): Check that provider fee is even a valid list item
return [
// [t`Liquidity provider fee`, `${swap.lpFee} ${inputSymbol}`], if (feeOptions) {
[ const parsedConvenienceFee = formatCurrencyAmount(outputAmount.multiply(feeOptions.fee), 6, i18n.locale)
rows.push([
t`${integrator} fee`, t`${integrator} fee`,
feeOptions && `${parsedConvenienceFee} ${outputCurrency.symbol || currencyId(outputCurrency)}`,
`${outputAmount.multiply(feeOptions.fee).toSignificant(2)} ${ ])
outputCurrency.symbol || currencyId(outputCurrency) }
}`,
],
[
t`Price impact`,
`${priceImpact.toFixed(2)}%`,
!priceImpact.lessThan(ALLOWED_PRICE_IMPACT_HIGH)
? 'error'
: !priceImpact.lessThan(ALLOWED_PRICE_IMPACT_MEDIUM)
? 'warning'
: undefined,
],
trade.tradeType === TradeType.EXACT_INPUT
? [t`Maximum sent`, `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${inputCurrency.symbol}`]
: [],
trade.tradeType === TradeType.EXACT_OUTPUT
? [t`Minimum received`, `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${outputCurrency.symbol}`]
: [],
[
t`Slippage tolerance`,
`${allowedSlippage.toFixed(2)}%`,
!allowedSlippage.lessThan(MIN_HIGH_SLIPPAGE) && 'warning',
],
].filter(isDetail)
function isDetail(detail: unknown[]): detail is [string, string, Color | undefined] { const priceImpactRow = [t`Price impact`, `${priceImpact.toFixed(2)}%`]
return Boolean(detail[1]) if (!priceImpact.lessThan(ALLOWED_PRICE_IMPACT_HIGH)) {
priceImpactRow.push('error')
} else if (!priceImpact.lessThan(ALLOWED_PRICE_IMPACT_MEDIUM)) {
priceImpactRow.push('warning')
} }
}, [allowedSlippage, inputCurrency, integrator, feeOptions, outputAmount, outputCurrency, priceImpact, trade]) rows.push(priceImpactRow)
if (trade.tradeType === TradeType.EXACT_INPUT) {
const localizedMaxSent = formatCurrencyAmount(trade.maximumAmountIn(allowedSlippage), 6, i18n.locale)
rows.push([t`Maximum sent`, `${localizedMaxSent} ${inputCurrency.symbol}`])
}
if (trade.tradeType === TradeType.EXACT_OUTPUT) {
const localizedMaxSent = formatCurrencyAmount(trade.minimumAmountOut(allowedSlippage), 6, i18n.locale)
rows.push([t`Minimum received`, `${localizedMaxSent} ${outputCurrency.symbol}`])
}
const slippageToleranceRow = [t`Slippage tolerance`, `${allowedSlippage.toFixed(2)}%`]
if (!allowedSlippage.lessThan(MIN_HIGH_SLIPPAGE)) {
slippageToleranceRow.push('warning')
}
rows.push(slippageToleranceRow)
return rows
}, [allowedSlippage, feeOptions, inputCurrency, integrator, i18n, outputAmount, outputCurrency, priceImpact, trade])
return ( return (
<> <>
{details.map(([label, detail, color]) => ( {details.map(([label, detail, color]) => (
<Detail key={label} label={label} value={detail} color={color} /> <Detail key={label} label={label} value={detail} color={color as Color} />
))} ))}
</> </>
) )
......
import { useLingui } from '@lingui/react'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { useUSDCValue } from 'hooks/useUSDCPrice' import { useUSDCValue } from 'hooks/useUSDCPrice'
import { ArrowRight } from 'lib/icons' import { ArrowRight } from 'lib/icons'
...@@ -5,6 +6,7 @@ import styled from 'lib/theme' ...@@ -5,6 +6,7 @@ import styled from 'lib/theme'
import { ThemedText } from 'lib/theme' import { ThemedText } from 'lib/theme'
import { useMemo } from 'react' import { useMemo } from 'react'
import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact' import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import Column from '../../Column' import Column from '../../Column'
import Row from '../../Row' import Row from '../../Row'
...@@ -21,6 +23,7 @@ interface TokenValueProps { ...@@ -21,6 +23,7 @@ interface TokenValueProps {
} }
function TokenValue({ input, usdc, change }: TokenValueProps) { function TokenValue({ input, usdc, change }: TokenValueProps) {
const { i18n } = useLingui()
const percent = useMemo(() => { const percent = useMemo(() => {
if (change) { if (change) {
const percent = change.toPrecision(3) const percent = change.toPrecision(3)
...@@ -36,13 +39,13 @@ function TokenValue({ input, usdc, change }: TokenValueProps) { ...@@ -36,13 +39,13 @@ function TokenValue({ input, usdc, change }: TokenValueProps) {
<Row gap={0.375} justify="flex-start"> <Row gap={0.375} justify="flex-start">
<TokenImg token={input.currency} /> <TokenImg token={input.currency} />
<ThemedText.Body2> <ThemedText.Body2>
{input.toSignificant(6)} {input.currency.symbol} {formatCurrencyAmount(input, 6, i18n.locale)} {input.currency.symbol}
</ThemedText.Body2> </ThemedText.Body2>
</Row> </Row>
{usdc && usdcAmount && ( {usdc && usdcAmount && (
<Row justify="flex-start"> <Row justify="flex-start">
<ThemedText.Caption color="secondary"> <ThemedText.Caption color="secondary">
${usdcAmount.toFixed(2)} ${formatCurrencyAmount(usdcAmount, 2, i18n.locale)}
{change && <Percent gain={change > 0}> {percent}</Percent>} {change && <Percent gain={change > 0}> {percent}</Percent>}
</ThemedText.Caption> </ThemedText.Caption>
</Row> </Row>
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc' import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
...@@ -9,7 +10,9 @@ import { AlertTriangle, Expando, Info } from 'lib/icons' ...@@ -9,7 +10,9 @@ import { AlertTriangle, Expando, Info } from 'lib/icons'
import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings' import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import { Field, independentFieldAtom } from 'lib/state/swap' import { Field, independentFieldAtom } from 'lib/state/swap'
import styled, { ThemedText } from 'lib/theme' import styled, { ThemedText } from 'lib/theme'
import formatLocaleNumber from 'lib/utils/formatLocaleNumber'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { formatCurrencyAmount, formatPrice } from 'utils/formatCurrencyAmount'
import { computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedPriceImpact } from 'utils/prices'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer' import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
...@@ -110,6 +113,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial ...@@ -110,6 +113,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
const scrollbar = useScrollbar(details) const scrollbar = useScrollbar(details)
const { i18n } = useLingui()
if (!(inputAmount && outputAmount && inputCurrency && outputCurrency)) { if (!(inputAmount && outputAmount && inputCurrency && outputCurrency)) {
return null return null
} }
...@@ -121,7 +126,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial ...@@ -121,7 +126,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<SummaryColumn gap={0.75} flex justify="center"> <SummaryColumn gap={0.75} flex justify="center">
<Summary input={inputAmount} output={outputAmount} usdc={true} /> <Summary input={inputAmount} output={outputAmount} usdc={true} />
<ThemedText.Caption> <ThemedText.Caption>
1 {inputCurrency.symbol} = {executionPrice?.toSignificant(6)} {outputCurrency.symbol} {formatLocaleNumber({ number: 1, sigFigs: 1, locale: i18n.locale })} {inputCurrency.symbol} ={' '}
{formatPrice(executionPrice, 6, i18n.locale)} {outputCurrency.symbol}
</ThemedText.Caption> </ThemedText.Caption>
</SummaryColumn> </SummaryColumn>
<Rule /> <Rule />
...@@ -145,14 +151,15 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial ...@@ -145,14 +151,15 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<Trans>Output is estimated.</Trans> <Trans>Output is estimated.</Trans>
{independentField === Field.INPUT && ( {independentField === Field.INPUT && (
<Trans> <Trans>
You will send at most {trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {inputCurrency.symbol}{' '} You will send at most {formatCurrencyAmount(trade.maximumAmountIn(allowedSlippage), 6, i18n.locale)}{' '}
or the transaction will revert. {inputCurrency.symbol} or the transaction will revert.
</Trans> </Trans>
)} )}
{independentField === Field.OUTPUT && ( {independentField === Field.OUTPUT && (
<Trans> <Trans>
You will receive at least {trade.minimumAmountOut(allowedSlippage).toSignificant(6)}{' '} You will receive at least{' '}
{outputCurrency.symbol} or the transaction will revert. {formatCurrencyAmount(trade.minimumAmountOut(allowedSlippage), 6, i18n.locale)} {outputCurrency.symbol}{' '}
or the transaction will revert.
</Trans> </Trans>
)} )}
</Estimate> </Estimate>
......
import { useLingui } from '@lingui/react'
import { Currency } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance' import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
...@@ -21,6 +22,7 @@ import AutoSizer from 'react-virtualized-auto-sizer' ...@@ -21,6 +22,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'
import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window' import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window'
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant'
import { currencyId } from 'utils/currencyId' import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { BaseButton } from '../Button' import { BaseButton } from '../Button'
import Column from '../Column' import Column from '../Column'
...@@ -69,6 +71,7 @@ interface BubbledEvent extends SyntheticEvent { ...@@ -69,6 +71,7 @@ interface BubbledEvent extends SyntheticEvent {
} }
function TokenOption({ index, value, style }: TokenOptionProps) { function TokenOption({ index, value, style }: TokenOptionProps) {
const { i18n } = useLingui()
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
// Annotate the event to be handled later instead of passing in handlers to avoid rerenders. // Annotate the event to be handled later instead of passing in handlers to avoid rerenders.
// This prevents token logos from reloading and flashing on the screen. // This prevents token logos from reloading and flashing on the screen.
...@@ -101,7 +104,7 @@ function TokenOption({ index, value, style }: TokenOptionProps) { ...@@ -101,7 +104,7 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
<ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption> <ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption>
</Column> </Column>
</Row> </Row>
{balance?.greaterThan(0) && balance?.toFixed(2)} {balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)}
</Row> </Row>
</ThemedText.Body1> </ThemedText.Body1>
</TokenButton> </TokenButton>
......
import { SUPPORTED_LOCALES, SupportedLocale } from 'constants/locales'
import formatLocaleNumber from './formatLocaleNumber'
const INPUT = 4000000.123 // 4 million
function expectedOutput(l: SupportedLocale): string {
switch (l) {
case 'en-US':
case 'he-IL':
case 'ja-JP':
case 'ko-KR':
case 'zh-CN':
case 'sw-TZ':
case 'zh-TW':
return `4,000,000.123`
case 'fr-FR':
return `4 000 000,123`
case 'ar-SA':
return `٤٬٠٠٠٬٠٠٠٫١٢٣`
case 'cs-CZ':
case 'fi-FI':
case 'af-ZA':
case 'hu-HU':
case 'no-NO':
case 'pl-PL':
case 'pt-PT':
case 'ru-RU':
case 'sv-SE':
case 'uk-UA':
return `4 000 000,123`
case 'ca-ES':
case 'da-DK':
case 'de-DE':
case 'el-GR':
case 'es-ES':
case 'id-ID':
case 'it-IT':
case 'nl-NL':
case 'pt-BR':
case 'ro-RO':
case 'sr-SP':
case 'tr-TR':
case 'vi-VN':
return `4.000.000,123`
default:
throw new Error('unreachable')
}
}
const TEST_MATRIX = SUPPORTED_LOCALES.map((locale) => ({
locale,
input: INPUT,
expected: expectedOutput(locale),
}))
describe('formatLocaleNumber', () => {
test.concurrent.each(TEST_MATRIX)('should format correctly for %p', async ({ locale, input, expected }) => {
const result = formatLocaleNumber({ number: input, locale })
expect(result).toEqual(expected)
})
})
import { Currency, CurrencyAmount, Price } from '@uniswap/sdk-core'
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from 'constants/locales'
interface FormatLocaleNumberArgs {
number: CurrencyAmount<Currency> | Price<Currency, Currency> | number
locale: string | null | undefined
options?: Intl.NumberFormatOptions
sigFigs?: number
}
export default function formatLocaleNumber({ number, locale, sigFigs, options = {} }: FormatLocaleNumberArgs): string {
let localeArg: string | string[]
if (!locale || (locale && !SUPPORTED_LOCALES.includes(locale))) {
localeArg = DEFAULT_LOCALE
} else {
localeArg = [locale, DEFAULT_LOCALE]
}
if (typeof number === 'number') {
return number.toLocaleString(localeArg, options)
} else {
return parseFloat(number.toSignificant(sigFigs)).toLocaleString(localeArg, options)
}
}
import { Currency, CurrencyAmount, Fraction, Price } from '@uniswap/sdk-core' import { Currency, CurrencyAmount, Fraction, Price } from '@uniswap/sdk-core'
import { DEFAULT_LOCALE, SupportedLocale } from 'constants/locales'
import JSBI from 'jsbi' import JSBI from 'jsbi'
import formatLocaleNumber from 'lib/utils/formatLocaleNumber'
export function formatCurrencyAmount(amount: CurrencyAmount<Currency> | undefined, sigFigs: number) { export function formatCurrencyAmount(
amount: CurrencyAmount<Currency> | undefined,
sigFigs: number,
locale: SupportedLocale = DEFAULT_LOCALE
): string {
if (!amount) { if (!amount) {
return '-' return '-'
} }
...@@ -11,20 +17,24 @@ export function formatCurrencyAmount(amount: CurrencyAmount<Currency> | undefine ...@@ -11,20 +17,24 @@ export function formatCurrencyAmount(amount: CurrencyAmount<Currency> | undefine
} }
if (amount.divide(amount.decimalScale).lessThan(new Fraction(1, 100000))) { if (amount.divide(amount.decimalScale).lessThan(new Fraction(1, 100000))) {
return '<0.00001' return `<${formatLocaleNumber({ number: 0.00001, locale })}`
} }
return amount.toSignificant(sigFigs) return formatLocaleNumber({ number: amount, locale, sigFigs })
} }
export function formatPrice(price: Price<Currency, Currency> | undefined, sigFigs: number) { export function formatPrice(
price: Price<Currency, Currency> | undefined,
sigFigs: number,
locale: SupportedLocale = DEFAULT_LOCALE
): string {
if (!price) { if (!price) {
return '-' return '-'
} }
if (parseFloat(price.toFixed(sigFigs)) < 0.0001) { if (parseFloat(price.toFixed(sigFigs)) < 0.0001) {
return '<0.0001' return `<${formatLocaleNumber({ number: 0.00001, locale })}`
} }
return price.toSignificant(sigFigs) return formatLocaleNumber({ number: price, locale, sigFigs })
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment