Skip to content

Commit

Permalink
feat(widgets): Localize CurrencyAmounts and Prices (#3247)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
JFrankfurt authored Feb 8, 2022
1 parent 0ec2dd4 commit f95275d
Show file tree
Hide file tree
Showing 10 changed files with 167 additions and 50 deletions.
2 changes: 1 addition & 1 deletion src/constants/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const SUPPORTED_LOCALES = [
'vi-VN',
'zh-CN',
'zh-TW',
] as const
]
export type SupportedLocale = typeof SUPPORTED_LOCALES[number] | 'pseudo'

// eslint-disable-next-line import/first
Expand Down
4 changes: 3 additions & 1 deletion src/lib/components/Swap/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useUSDCValue } from 'hooks/useUSDCPrice'
import { useAtomValue } from 'jotai/utils'
import { loadingOpacityCss } from 'lib/css/loading'
Expand Down Expand Up @@ -34,6 +35,7 @@ interface InputProps {
}

export default function Input({ disabled }: InputProps) {
const { i18n } = useLingui()
const {
trade: { state: tradeState },
currencyBalances: { [Field.INPUT]: balance },
Expand Down Expand Up @@ -85,7 +87,7 @@ export default function Input({ disabled }: InputProps) {
<LoadingSpan $loading={isLoading}>{inputUSDC ? `$${inputUSDC.toFixed(2)}` : '-'}</LoadingSpan>
{balance && (
<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>
)}
</Row>
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/Swap/Output.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useUSDCValue } from 'hooks/useUSDCPrice'
import { atom } from 'jotai'
import { useAtomValue } from 'jotai/utils'
Expand Down Expand Up @@ -45,6 +46,8 @@ interface OutputProps {
}

export default function Output({ disabled, children }: OutputProps) {
const { i18n } = useLingui()

const {
trade: { state: tradeState },
currencyBalances: { [Field.OUTPUT]: balance },
Expand Down Expand Up @@ -106,7 +109,7 @@ export default function Output({ disabled, children }: OutputProps) {
<LoadingSpan $loading={isLoading}>{usdc}</LoadingSpan>
{balance && (
<span>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4)}</span>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span>
</span>
)}
</Row>
Expand Down
70 changes: 37 additions & 33 deletions src/lib/components/Swap/Summary/Details.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
Expand All @@ -8,6 +9,7 @@ import { feeOptionsAtom } from 'lib/state/swap'
import styled, { Color, ThemedText } from 'lib/theme'
import { useMemo } from 'react'
import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { computeRealizedPriceImpact } from 'utils/prices'

import Row from '../../Row'
Expand Down Expand Up @@ -48,47 +50,49 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
const integrator = window.location.hostname
const feeOptions = useAtomValue(feeOptionsAtom)

const { i18n } = useLingui()
const details = useMemo(() => {
const rows = []
// @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`,
feeOptions &&
`${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)
`${parsedConvenienceFee} ${outputCurrency.symbol || currencyId(outputCurrency)}`,
])
}

function isDetail(detail: unknown[]): detail is [string, string, Color | undefined] {
return Boolean(detail[1])
const priceImpactRow = [t`Price impact`, `${priceImpact.toFixed(2)}%`]
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 (
<>
{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} />
))}
</>
)
Expand Down
7 changes: 5 additions & 2 deletions src/lib/components/Swap/Summary/Summary.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useLingui } from '@lingui/react'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { useUSDCValue } from 'hooks/useUSDCPrice'
import { ArrowRight } from 'lib/icons'
import styled from 'lib/theme'
import { ThemedText } from 'lib/theme'
import { useMemo } from 'react'
import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'

import Column from '../../Column'
import Row from '../../Row'
Expand All @@ -21,6 +23,7 @@ interface TokenValueProps {
}

function TokenValue({ input, usdc, change }: TokenValueProps) {
const { i18n } = useLingui()
const percent = useMemo(() => {
if (change) {
const percent = change.toPrecision(3)
Expand All @@ -36,13 +39,13 @@ function TokenValue({ input, usdc, change }: TokenValueProps) {
<Row gap={0.375} justify="flex-start">
<TokenImg token={input.currency} />
<ThemedText.Body2>
{input.toSignificant(6)} {input.currency.symbol}
{formatCurrencyAmount(input, 6, i18n.locale)} {input.currency.symbol}
</ThemedText.Body2>
</Row>
{usdc && usdcAmount && (
<Row justify="flex-start">
<ThemedText.Caption color="secondary">
${usdcAmount.toFixed(2)}
${formatCurrencyAmount(usdcAmount, 2, i18n.locale)}
{change && <Percent gain={change > 0}> {percent}</Percent>}
</ThemedText.Caption>
</Row>
Expand Down
17 changes: 12 additions & 5 deletions src/lib/components/Swap/Summary/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
Expand All @@ -9,7 +10,9 @@ import { AlertTriangle, Expando, Info } from 'lib/icons'
import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import { Field, independentFieldAtom } from 'lib/state/swap'
import styled, { ThemedText } from 'lib/theme'
import formatLocaleNumber from 'lib/utils/formatLocaleNumber'
import { useMemo, useState } from 'react'
import { formatCurrencyAmount, formatPrice } from 'utils/formatCurrencyAmount'
import { computeRealizedPriceImpact } from 'utils/prices'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'

Expand Down Expand Up @@ -110,6 +113,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial

const scrollbar = useScrollbar(details)

const { i18n } = useLingui()

if (!(inputAmount && outputAmount && inputCurrency && outputCurrency)) {
return null
}
Expand All @@ -121,7 +126,8 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<SummaryColumn gap={0.75} flex justify="center">
<Summary input={inputAmount} output={outputAmount} usdc={true} />
<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>
</SummaryColumn>
<Rule />
Expand All @@ -145,14 +151,15 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
<Trans>Output is estimated.</Trans>
{independentField === Field.INPUT && (
<Trans>
You will send at most {trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {inputCurrency.symbol}{' '}
or the transaction will revert.
You will send at most {formatCurrencyAmount(trade.maximumAmountIn(allowedSlippage), 6, i18n.locale)}{' '}
{inputCurrency.symbol} or the transaction will revert.
</Trans>
)}
{independentField === Field.OUTPUT && (
<Trans>
You will receive at least {trade.minimumAmountOut(allowedSlippage).toSignificant(6)}{' '}
{outputCurrency.symbol} or the transaction will revert.
You will receive at least{' '}
{formatCurrencyAmount(trade.minimumAmountOut(allowedSlippage), 6, i18n.locale)} {outputCurrency.symbol}{' '}
or the transaction will revert.
</Trans>
)}
</Estimate>
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/TokenSelect/TokenOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useLingui } from '@lingui/react'
import { Currency } from '@uniswap/sdk-core'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
Expand All @@ -21,6 +22,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'
import { areEqual, FixedSizeList, FixedSizeListProps } from 'react-window'
import invariant from 'tiny-invariant'
import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'

import { BaseButton } from '../Button'
import Column from '../Column'
Expand Down Expand Up @@ -69,6 +71,7 @@ interface BubbledEvent extends SyntheticEvent {
}

function TokenOption({ index, value, style }: TokenOptionProps) {
const { i18n } = useLingui()
const ref = useRef<HTMLButtonElement>(null)
// 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.
Expand Down Expand Up @@ -101,7 +104,7 @@ function TokenOption({ index, value, style }: TokenOptionProps) {
<ThemedText.Caption color="secondary">{value.name}</ThemedText.Caption>
</Column>
</Row>
{balance?.greaterThan(0) && balance?.toFixed(2)}
{balance?.greaterThan(0) && formatCurrencyAmount(balance, 2, i18n.locale)}
</Row>
</ThemedText.Body1>
</TokenButton>
Expand Down
62 changes: 62 additions & 0 deletions src/lib/utils/formatLocaleNumber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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)
})
})
23 changes: 23 additions & 0 deletions src/lib/utils/formatLocaleNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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)
}
}
Loading

0 comments on commit f95275d

Please sign in to comment.