feat: currency selector (#7196)

* adding currency settings option

* moving menu item to shared component

* adding supported currencies

* currency menu items

* currency url params

* currency selector e2e tests

* fixing tests

* currency icons

* removing eslint

* removing another eslint disable

* renaming to local currency

* more name changes

* design updates

* renaming file

* fixing lint

* Update src/components/AccountDrawer/SettingsMenu.tsx

Co-authored-by: Charles Bachmeier <charles@bachmeier.io>

* alphabetical ordering currencies

* column padding

* padding only for mobile

* memoizing into switch

---------

Co-authored-by: Charles Bachmeier <charles@bachmeier.io>
This commit is contained in:
Jack Short 2023-08-30 14:48:53 -04:00 committed by GitHub
parent ea66b8b959
commit 4eda18a4d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1085 additions and 49 deletions

@ -144,4 +144,32 @@ describe('Wallet Dropdown', () => {
cy.get(getTestSelector('wallet-settings')).should('not.be.visible') cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
}) })
}) })
describe('local currency', () => {
it('loads local currency from the query param', () => {
cy.visit('/', { featureFlags: [FeatureFlag.currencyConversion] })
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.contains('USD')
cy.visit('/?cur=AUD', { featureFlags: [FeatureFlag.currencyConversion] })
cy.contains('AUD')
})
it('loads local currency from menu', () => {
cy.visit('/', { featureFlags: [FeatureFlag.currencyConversion] })
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.contains('USD')
cy.get(getTestSelector('local-currency-settings-button')).click()
cy.get(getTestSelector('wallet-local-currency-item')).contains('AUD').click({ force: true })
cy.location('hash').should('match', /\?cur=AUD$/)
cy.contains('AUD')
cy.get(getTestSelector('wallet-local-currency-item')).contains('USD').click({ force: true })
cy.location('hash').should('match', /\?cur=USD$/)
cy.contains('USD')
})
})
}) })

@ -1,11 +1,12 @@
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import Column from 'components/Column' import Column from 'components/Column'
import WalletModal from 'components/WalletModal' import WalletModal from 'components/WalletModal'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import AuthenticatedHeader from './AuthenticatedHeader' import AuthenticatedHeader from './AuthenticatedHeader'
import LanguageMenu from './LanguageMenu' import LanguageMenu from './LanguageMenu'
import LocalCurrencyMenu from './LocalCurrencyMenu'
import SettingsMenu from './SettingsMenu' import SettingsMenu from './SettingsMenu'
const DefaultMenuWrap = styled(Column)` const DefaultMenuWrap = styled(Column)`
@ -17,6 +18,7 @@ enum MenuState {
DEFAULT, DEFAULT,
SETTINGS, SETTINGS,
LANGUAGE_SETTINGS, LANGUAGE_SETTINGS,
LOCAL_CURRENCY_SETTINGS,
} }
function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) { function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) {
@ -27,6 +29,7 @@ function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) {
const openSettings = useCallback(() => setMenu(MenuState.SETTINGS), []) const openSettings = useCallback(() => setMenu(MenuState.SETTINGS), [])
const closeSettings = useCallback(() => setMenu(MenuState.DEFAULT), []) const closeSettings = useCallback(() => setMenu(MenuState.DEFAULT), [])
const openLanguageSettings = useCallback(() => setMenu(MenuState.LANGUAGE_SETTINGS), []) const openLanguageSettings = useCallback(() => setMenu(MenuState.LANGUAGE_SETTINGS), [])
const openLocalCurrencySettings = useCallback(() => setMenu(MenuState.LOCAL_CURRENCY_SETTINGS), [])
useEffect(() => { useEffect(() => {
if (!drawerOpen && menu !== MenuState.DEFAULT) { if (!drawerOpen && menu !== MenuState.DEFAULT) {
@ -39,20 +42,30 @@ function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) {
return return
}, [drawerOpen, menu, closeSettings]) }, [drawerOpen, menu, closeSettings])
return ( const SubMenu = useMemo(() => {
<DefaultMenuWrap> switch (menu) {
{menu === MenuState.DEFAULT && case MenuState.DEFAULT:
(isAuthenticated ? ( return isAuthenticated ? (
<AuthenticatedHeader account={account} openSettings={openSettings} /> <AuthenticatedHeader account={account} openSettings={openSettings} />
) : ( ) : (
<WalletModal openSettings={openSettings} /> <WalletModal openSettings={openSettings} />
))} )
{menu === MenuState.SETTINGS && ( case MenuState.SETTINGS:
<SettingsMenu onClose={closeSettings} openLanguageSettings={openLanguageSettings} /> return (
)} <SettingsMenu
{menu === MenuState.LANGUAGE_SETTINGS && <LanguageMenu onClose={openSettings} />} onClose={closeSettings}
</DefaultMenuWrap> openLanguageSettings={openLanguageSettings}
) openLocalCurrencySettings={openLocalCurrencySettings}
/>
)
case MenuState.LANGUAGE_SETTINGS:
return <LanguageMenu onClose={openSettings} />
case MenuState.LOCAL_CURRENCY_SETTINGS:
return <LocalCurrencyMenu onClose={openSettings} />
}
}, [account, closeSettings, isAuthenticated, menu, openLanguageSettings, openLocalCurrencySettings, openSettings])
return <DefaultMenuWrap>{SubMenu}</DefaultMenuWrap>
} }
export default DefaultMenu export default DefaultMenu

@ -2,36 +2,21 @@ import { Trans } from '@lingui/macro'
import { LOCALE_LABEL, SUPPORTED_LOCALES, SupportedLocale } from 'constants/locales' import { LOCALE_LABEL, SUPPORTED_LOCALES, SupportedLocale } from 'constants/locales'
import { useActiveLocale } from 'hooks/useActiveLocale' import { useActiveLocale } from 'hooks/useActiveLocale'
import { useLocationLinkProps } from 'hooks/useLocationLinkProps' import { useLocationLinkProps } from 'hooks/useLocationLinkProps'
import { Check } from 'react-feather'
import { Link } from 'react-router-dom'
import styled, { useTheme } from 'styled-components'
import { ClickableStyle, ThemedText } from 'theme'
import { MenuColumn, MenuItem } from './shared'
import { SlideOutMenu } from './SlideOutMenu' import { SlideOutMenu } from './SlideOutMenu'
const InternalLinkMenuItem = styled(Link)`
${ClickableStyle}
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
justify-content: space-between;
text-decoration: none;
color: ${({ theme }) => theme.neutral1};
`
function LanguageMenuItem({ locale, isActive }: { locale: SupportedLocale; isActive: boolean }) { function LanguageMenuItem({ locale, isActive }: { locale: SupportedLocale; isActive: boolean }) {
const { to, onClick } = useLocationLinkProps(locale) const { to, onClick } = useLocationLinkProps(locale)
const theme = useTheme()
if (!to) return null
return ( return (
<InternalLinkMenuItem onClick={onClick} to={to}> <MenuItem
<ThemedText.BodySmall data-testid="wallet-language-item">{LOCALE_LABEL[locale]}</ThemedText.BodySmall> label={LOCALE_LABEL[locale]}
{isActive && <Check color={theme.accent1} opacity={1} size={20} />} onClick={onClick}
</InternalLinkMenuItem> to={to}
isActive={isActive}
testId="wallet-language-item"
/>
) )
} }
@ -50,7 +35,9 @@ export function LanguageMenuItems() {
export default function LanguageMenu({ onClose }: { onClose: () => void }) { export default function LanguageMenu({ onClose }: { onClose: () => void }) {
return ( return (
<SlideOutMenu title={<Trans>Language</Trans>} onClose={onClose}> <SlideOutMenu title={<Trans>Language</Trans>} onClose={onClose}>
<LanguageMenuItems /> <MenuColumn>
<LanguageMenuItems />
</MenuColumn>
</SlideOutMenu> </SlideOutMenu>
) )
} }

@ -0,0 +1,61 @@
import { Trans } from '@lingui/macro'
import { getLocalCurrencyIcon, SUPPORTED_LOCAL_CURRENCIES, SupportedLocalCurrency } from 'constants/localCurrencies'
import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency'
import { useLocalCurrencyLinkProps } from 'hooks/useLocalCurrencyLinkProps'
import { useMemo } from 'react'
import styled from 'styled-components'
import { MenuColumn, MenuItem } from './shared'
import { SlideOutMenu } from './SlideOutMenu'
const StyledLocalCurrencyIcon = styled.div`
width: 20px;
height: 20px;
border-radius: 100%;
overflow: hidden;
`
function LocalCurrencyMenuItem({
localCurrency,
isActive,
}: {
localCurrency: SupportedLocalCurrency
isActive: boolean
}) {
const { to, onClick } = useLocalCurrencyLinkProps(localCurrency)
const LocalCurrencyIcon = useMemo(() => {
return <StyledLocalCurrencyIcon>{getLocalCurrencyIcon(localCurrency)}</StyledLocalCurrencyIcon>
}, [localCurrency])
if (!to) return null
return (
<MenuItem
label={localCurrency}
logo={LocalCurrencyIcon}
isActive={isActive}
to={to}
onClick={onClick}
testId="wallet-local-currency-item"
/>
)
}
export default function LocalCurrencyMenu({ onClose }: { onClose: () => void }) {
const activeLocalCurrency = useActiveLocalCurrency()
return (
<SlideOutMenu title={<Trans>Currency</Trans>} onClose={onClose}>
<MenuColumn>
{SUPPORTED_LOCAL_CURRENCIES.map((localCurrency) => (
<LocalCurrencyMenuItem
localCurrency={localCurrency}
isActive={activeLocalCurrency === localCurrency}
key={localCurrency}
/>
))}
</MenuColumn>
</SlideOutMenu>
)
}

@ -3,6 +3,7 @@ import Column from 'components/Column'
import Row from 'components/Row' import Row from 'components/Row'
import { LOCALE_LABEL } from 'constants/locales' import { LOCALE_LABEL } from 'constants/locales'
import { useCurrencyConversionFlagEnabled } from 'featureFlags/flags/currencyConversion' import { useCurrencyConversionFlagEnabled } from 'featureFlags/flags/currencyConversion'
import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency'
import { useActiveLocale } from 'hooks/useActiveLocale' import { useActiveLocale } from 'hooks/useActiveLocale'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { ChevronRight } from 'react-feather' import { ChevronRight } from 'react-feather'
@ -27,15 +28,16 @@ const SectionTitle = styled(ThemedText.SubHeader)`
padding-bottom: 24px; padding-bottom: 24px;
` `
const ToggleWrapper = styled.div` const ToggleWrapper = styled.div<{ currencyConversionEnabled?: boolean }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: ${({ currencyConversionEnabled }) => (currencyConversionEnabled ? '10px' : '24px')};
` `
const SettingsButtonWrapper = styled(Row)` const SettingsButtonWrapper = styled(Row)`
${ClickableStyle} ${ClickableStyle}
padding: 16px 0px;
` `
const StyledChevron = styled(ChevronRight)` const StyledChevron = styled(ChevronRight)`
@ -60,7 +62,7 @@ const SettingsButton = ({
<SettingsButtonWrapper data-testid={testId} align="center" justify="space-between" onClick={onClick}> <SettingsButtonWrapper data-testid={testId} align="center" justify="space-between" onClick={onClick}>
<ThemedText.SubHeaderSmall color="textPrimary">{title}</ThemedText.SubHeaderSmall> <ThemedText.SubHeaderSmall color="textPrimary">{title}</ThemedText.SubHeaderSmall>
<LanguageLabel gap="xs" align="center" width="min-content"> <LanguageLabel gap="xs" align="center" width="min-content">
<ThemedText.LabelMedium color="textPrimary">{currentState}</ThemedText.LabelMedium> <ThemedText.LabelSmall color="textPrimary">{currentState}</ThemedText.LabelSmall>
<StyledChevron size={20} /> <StyledChevron size={20} />
</LanguageLabel> </LanguageLabel>
</SettingsButtonWrapper> </SettingsButtonWrapper>
@ -69,12 +71,15 @@ const SettingsButton = ({
export default function SettingsMenu({ export default function SettingsMenu({
onClose, onClose,
openLanguageSettings, openLanguageSettings,
openLocalCurrencySettings,
}: { }: {
onClose: () => void onClose: () => void
openLanguageSettings: () => void openLanguageSettings: () => void
openLocalCurrencySettings: () => void
}) { }) {
const currencyConversionEnabled = useCurrencyConversionFlagEnabled() const currencyConversionEnabled = useCurrencyConversionFlagEnabled()
const activeLocale = useActiveLocale() const activeLocale = useActiveLocale()
const activeLocalCurrency = useActiveLocalCurrency()
return ( return (
<SlideOutMenu title={<Trans>Settings</Trans>} onClose={onClose}> <SlideOutMenu title={<Trans>Settings</Trans>} onClose={onClose}>
@ -83,7 +88,7 @@ export default function SettingsMenu({
<SectionTitle data-testid="wallet-header"> <SectionTitle data-testid="wallet-header">
<Trans>Preferences</Trans> <Trans>Preferences</Trans>
</SectionTitle> </SectionTitle>
<ToggleWrapper> <ToggleWrapper currencyConversionEnabled={currencyConversionEnabled}>
<ThemeToggle /> <ThemeToggle />
<SmallBalanceToggle /> <SmallBalanceToggle />
<AnalyticsToggle /> <AnalyticsToggle />
@ -99,12 +104,20 @@ export default function SettingsMenu({
)} )}
{currencyConversionEnabled && ( {currencyConversionEnabled && (
<SettingsButton <Column>
title={<Trans>Language</Trans>} <SettingsButton
currentState={LOCALE_LABEL[activeLocale]} title={<Trans>Language</Trans>}
onClick={openLanguageSettings} currentState={LOCALE_LABEL[activeLocale]}
testId="language-settings-button" onClick={openLanguageSettings}
/> testId="language-settings-button"
/>
<SettingsButton
title={<Trans>Currency</Trans>}
currentState={activeLocalCurrency}
onClick={openLocalCurrencySettings}
testId="local-currency-settings-button"
/>
</Column>
)} )}
</div> </div>
<GitVersionRow /> <GitVersionRow />

@ -0,0 +1,57 @@
import Column from 'components/Column'
import Row from 'components/Row'
import { ReactNode } from 'react'
import { Check } from 'react-feather'
import type { To } from 'react-router-dom'
import { Link } from 'react-router-dom'
import styled, { useTheme } from 'styled-components'
import { BREAKPOINTS, ClickableStyle, ThemedText } from 'theme'
const InternalLinkMenuItem = styled(Link)`
${ClickableStyle}
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
justify-content: space-between;
text-decoration: none;
color: ${({ theme }) => theme.neutral1};
`
export const MenuColumn = styled(Column)`
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
padding-bottom: 14px;
}
`
export function MenuItem({
label,
logo,
to,
onClick,
isActive,
testId,
}: {
label: ReactNode
logo?: ReactNode
to?: To
onClick?: () => void
isActive: boolean
testId?: string
}) {
const theme = useTheme()
if (!to) return null
return (
<InternalLinkMenuItem onClick={onClick} to={to}>
<Row gap="md">
{logo && logo}
<ThemedText.BodySmall data-testid={testId}>{label}</ThemedText.BodySmall>
</Row>
{isActive && <Check color={theme.accent1} opacity={1} size={20} />}
</InternalLinkMenuItem>
)
}

@ -0,0 +1,90 @@
import { ReactNode } from 'react'
import {
AUD_ICON,
BRL_ICON,
CAD_ICON,
EUR_ICON,
GBP_ICON,
HKD_ICON,
IDR_ICON,
INR_ICON,
JPY_ICON,
NGN_ICON,
PKR_ICON,
RUB_ICON,
SGD_ICON,
THB_ICON,
TRY_ICON,
UAH_ICON,
USD_ICON,
VND_ICON,
} from './localCurrencyIcons'
export const SUPPORTED_LOCAL_CURRENCIES = [
'USD',
'AUD',
'BRL',
'CAD',
'EUR',
'GBP',
'HKD',
'IDR',
'INR',
'JPY',
'NGN',
'PKR',
'RUB',
'SGD',
'THB',
'TRY',
'UAH',
'VND',
]
export type SupportedLocalCurrency = (typeof SUPPORTED_LOCAL_CURRENCIES)[number]
export const DEFAULT_LOCAL_CURRENCY: SupportedLocalCurrency = 'USD'
export function getLocalCurrencyIcon(localCurrency: SupportedLocalCurrency, size = 20): ReactNode {
switch (localCurrency) {
case 'USD':
return <USD_ICON width={size} height={size} />
case 'EUR':
return <EUR_ICON width={size} height={size} />
case 'RUB':
return <RUB_ICON width={size} height={size} />
case 'INR':
return <INR_ICON width={size} height={size} />
case 'GBP':
return <GBP_ICON width={size} height={size} />
case 'JPY':
return <JPY_ICON width={size} height={size} />
case 'VND':
return <VND_ICON width={size} height={size} />
case 'SGD':
return <SGD_ICON width={size} height={size} />
case 'BRL':
return <BRL_ICON width={size} height={size} />
case 'HKD':
return <HKD_ICON width={size} height={size} />
case 'CAD':
return <CAD_ICON width={size} height={size} />
case 'IDR':
return <IDR_ICON width={size} height={size} />
case 'TRY':
return <TRY_ICON width={size} height={size} />
case 'NGN':
return <NGN_ICON width={size} height={size} />
case 'AUD':
return <AUD_ICON width={size} height={size} />
case 'PKR':
return <PKR_ICON width={size} height={size} />
case 'UAH':
return <UAH_ICON width={size} height={size} />
case 'THB':
return <THB_ICON width={size} height={size} />
default:
return null
}
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,29 @@
import { DEFAULT_LOCAL_CURRENCY, SUPPORTED_LOCAL_CURRENCIES, SupportedLocalCurrency } from 'constants/localCurrencies'
import { atomWithStorage, useAtomValue } from 'jotai/utils'
import { useMemo } from 'react'
import useParsedQueryString from './useParsedQueryString'
export const activeLocalCurrencyAtom = atomWithStorage<SupportedLocalCurrency>(
'activeLocalCurrency',
DEFAULT_LOCAL_CURRENCY
)
function useUrlLocalCurrency() {
const parsed = useParsedQueryString()
const parsedLocalCurrency = parsed.cur
if (typeof parsedLocalCurrency !== 'string') return undefined
const lowerCaseSupportedLocalCurrency = parsedLocalCurrency.toLowerCase()
return SUPPORTED_LOCAL_CURRENCIES.find(
(localCurrency) => localCurrency.toLowerCase() === lowerCaseSupportedLocalCurrency
)
}
export function useActiveLocalCurrency(): SupportedLocalCurrency {
const activeLocalCurrency = useAtomValue(activeLocalCurrencyAtom)
const urlLocalCurrency = useUrlLocalCurrency()
return useMemo(() => urlLocalCurrency ?? activeLocalCurrency, [activeLocalCurrency, urlLocalCurrency])
}

@ -0,0 +1,40 @@
import { sendAnalyticsEvent } from 'analytics'
import { SupportedLocalCurrency } from 'constants/localCurrencies'
import useParsedQueryString from 'hooks/useParsedQueryString'
import { useAtom } from 'jotai'
import { stringify } from 'qs'
import { useMemo } from 'react'
import type { To } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { activeLocalCurrencyAtom, useActiveLocalCurrency } from './useActiveLocalCurrency'
export function useLocalCurrencyLinkProps(localCurrency?: SupportedLocalCurrency): {
to?: To
onClick?: () => void
} {
const location = useLocation()
const qs = useParsedQueryString()
const activeLocalCurrency = useActiveLocalCurrency()
const [, updateActiveLocalCurrency] = useAtom(activeLocalCurrencyAtom)
return useMemo(
() =>
!localCurrency
? {}
: {
to: {
...location,
search: stringify({ ...qs, cur: localCurrency }),
},
onClick: () => {
updateActiveLocalCurrency(localCurrency)
sendAnalyticsEvent('Local Currency Selected', {
previous_local_currency: activeLocalCurrency,
new_local_currency: localCurrency,
})
},
},
[localCurrency, location, qs, updateActiveLocalCurrency, activeLocalCurrency]
)
}

@ -40,9 +40,6 @@ export const ThemedText = {
Hero(props: TextProps) { Hero(props: TextProps) {
return <TextWrapper fontWeight={485} fontSize={48} color="neutral1" {...props} /> return <TextWrapper fontWeight={485} fontSize={48} color="neutral1" {...props} />
}, },
LabelMedium(props: TextProps) {
return <TextWrapper fontWeight={500} fontSize={16} color="textPrimary" lineHeight="20px" {...props} />
},
LabelSmall(props: TextProps) { LabelSmall(props: TextProps) {
return <TextWrapper fontWeight={485} fontSize={14} color="neutral2" {...props} /> return <TextWrapper fontWeight={485} fontSize={14} color="neutral2" {...props} />
}, },