diff options
author | Alexander Tseung <alextsg@gmail.com> | 2018-05-20 14:04:19 +0800 |
---|---|---|
committer | Alexander Tseung <alextsg@gmail.com> | 2018-05-20 14:04:19 +0800 |
commit | 4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9 (patch) | |
tree | ecc9cfa83e3570fc71389ee1fdfbcd030f416ddb /ui/app/components/pages/add-token | |
parent | 713c77db54e7866da169205d7f665c33a4c626e0 (diff) | |
download | tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar.gz tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar.bz2 tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar.lz tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar.xz tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.tar.zst tangerine-wallet-browser-4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9.zip |
Update designs for Add Token screen
Diffstat (limited to 'ui/app/components/pages/add-token')
14 files changed, 691 insertions, 0 deletions
diff --git a/ui/app/components/pages/add-token/add-token.component.js b/ui/app/components/pages/add-token/add-token.component.js new file mode 100644 index 000000000..885c7b2ac --- /dev/null +++ b/ui/app/components/pages/add-token/add-token.component.js @@ -0,0 +1,356 @@ +import React, { Component } from 'react' +import classnames from 'classnames' +import PropTypes from 'prop-types' +import ethUtil from 'ethereumjs-util' +import { checkExistingAddresses } from './util' +import { tokenInfoGetter } from '../../../token-util' +import { DEFAULT_ROUTE, CONFIRM_ADD_TOKEN_ROUTE } from '../../../routes' +import Button from '../../button' +import TextField from '../../text-field' +import TokenList from './token-list' +import TokenSearch from './token-search' + +const emptyAddr = '0x0000000000000000000000000000000000000000' +const SEARCH_TAB = 'SEARCH' +const CUSTOM_TOKEN_TAB = 'CUSTOM_TOKEN' + +class AddToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + setPendingTokens: PropTypes.func, + pendingTokens: PropTypes.object, + clearPendingTokens: PropTypes.func, + tokens: PropTypes.array, + identities: PropTypes.object, + } + + constructor (props) { + super(props) + + this.state = { + customAddress: '', + customSymbol: '', + customDecimals: 0, + searchResults: [], + selectedTokens: {}, + tokenSelectorError: null, + customAddressError: null, + customSymbolError: null, + customDecimalsError: null, + autoFilled: false, + displayedTab: SEARCH_TAB, + } + } + + componentDidMount () { + this.tokenInfoGetter = tokenInfoGetter() + const { pendingTokens = {} } = this.props + const pendingTokenKeys = Object.keys(pendingTokens) + + if (pendingTokenKeys.length > 0) { + let selectedTokens = {} + let customToken = {} + + pendingTokenKeys.forEach(tokenAddress => { + const token = pendingTokens[tokenAddress] + const { isCustom } = token + + if (isCustom) { + customToken = { ...token } + } else { + selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } } + } + }) + + const { + address: customAddress = '', + symbol: customSymbol = '', + decimals: customDecimals = 0, + } = customToken + + const displayedTab = Object.keys(selectedTokens).length > 0 ? SEARCH_TAB : CUSTOM_TOKEN_TAB + this.setState({ selectedTokens, customAddress, customSymbol, customDecimals, displayedTab }) + } + } + + handleToggleToken (token) { + const { address } = token + const { selectedTokens = {} } = this.state + const selectedTokensCopy = { ...selectedTokens } + + if (address in selectedTokensCopy) { + delete selectedTokensCopy[address] + } else { + selectedTokensCopy[address] = token + } + + this.setState({ + selectedTokens: selectedTokensCopy, + tokenSelectorError: null, + }) + } + + hasError () { + const { + tokenSelectorError, + customAddressError, + customSymbolError, + customDecimalsError, + } = this.state + + return tokenSelectorError || customAddressError || customSymbolError || customDecimalsError + } + + hasSelected () { + const { customAddress = '', selectedTokens = {} } = this.state + return customAddress || Object.keys(selectedTokens).length > 0 + } + + handleNext () { + if (this.hasError()) { + return + } + + if (!this.hasSelected()) { + this.setState({ tokenSelectorError: this.context.t('mustSelectOne') }) + return + } + + const { setPendingTokens, history } = this.props + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state + + const customToken = { + address, + symbol, + decimals, + } + + setPendingTokens({ customToken, selectedTokens }) + history.push(CONFIRM_ADD_TOKEN_ROUTE) + } + + async attemptToAutoFillTokenParams (address) { + const { symbol, decimals } = await this.tokenInfoGetter(address) + + if (symbol && decimals) { + this.setState({ + customSymbol: symbol, + customDecimals: decimals, + customSymbolError: null, + customDecimalsError: null, + autoFilled: true, + }) + } + } + + handleCustomAddressChange (value) { + const customAddress = value.trim() + this.setState({ + customAddress, + customAddressError: null, + tokenSelectorError: null, + autoFilled: false, + }) + + const isValidAddress = ethUtil.isValidAddress(customAddress) + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() + + switch (true) { + case !isValidAddress: + this.setState({ + customAddressError: this.context.t('invalidAddress'), + customSymbol: '', + customDecimals: 0, + customSymbolError: null, + customDecimalsError: null, + }) + + break + case Boolean(this.props.identities[standardAddress]): + this.setState({ + customAddressError: this.context.t('personalAddressDetected'), + }) + + break + case checkExistingAddresses(customAddress, this.props.tokens): + this.setState({ + customAddressError: this.context.t('tokenAlreadyAdded'), + }) + + break + default: + if (customAddress !== emptyAddr) { + this.attemptToAutoFillTokenParams(customAddress) + } + } + } + + handleCustomSymbolChange (value) { + const customSymbol = value.trim() + const symbolLength = customSymbol.length + let customSymbolError = null + + if (symbolLength <= 0 || symbolLength >= 10) { + customSymbolError = this.context.t('symbolBetweenZeroTen') + } + + this.setState({ customSymbol, customSymbolError }) + } + + handleCustomDecimalsChange (value) { + const customDecimals = value.trim() + const validDecimals = customDecimals !== null && + customDecimals !== '' && + customDecimals >= 0 && + customDecimals < 36 + let customDecimalsError = null + + if (!validDecimals) { + customDecimalsError = this.context.t('decimalsMustZerotoTen') + } + + this.setState({ customDecimals, customDecimalsError }) + } + + renderCustomTokenForm () { + const { + customAddress, + customSymbol, + customDecimals, + customAddressError, + customSymbolError, + customDecimalsError, + autoFilled, + } = this.state + + return ( + <div className="add-token__custom-token-form"> + <TextField + id="custom-address" + label="Token Address" + type="text" + value={customAddress} + onChange={e => this.handleCustomAddressChange(e.target.value)} + error={customAddressError} + fullWidth + margin="normal" + /> + <TextField + id="custom-symbol" + label="Token Symbol" + type="text" + value={customSymbol} + onChange={e => this.handleCustomSymbolChange(e.target.value)} + error={customSymbolError} + fullWidth + margin="normal" + disabled={autoFilled} + /> + <TextField + id="custom-decimals" + label="Decimals of Precision" + type="number" + value={customDecimals} + onChange={e => this.handleCustomDecimalsChange(e.target.value)} + error={customDecimalsError} + fullWidth + margin="normal" + disabled={autoFilled} + /> + </div> + ) + } + + renderSearchToken () { + const { tokenSelectorError, selectedTokens, searchResults } = this.state + + return ( + <div className="add-token__search-token"> + <TokenSearch + onSearch={({ results = [] }) => this.setState({ searchResults: results })} + error={tokenSelectorError} + /> + <div className="add-token__token-list"> + <TokenList + results={searchResults} + selectedTokens={selectedTokens} + onToggleToken={token => this.handleToggleToken(token)} + /> + </div> + </div> + ) + } + + render () { + const { displayedTab } = this.state + const { history, clearPendingTokens } = this.props + + return ( + <div className="page-container"> + <div className="page-container__header page-container__header--no-padding-bottom"> + <div className="page-container__title"> + { this.context.t('addTokens') } + </div> + <div className="page-container__tabs"> + <div + className={classnames('page-container__tab', { + 'page-container__tab--selected': displayedTab === SEARCH_TAB, + })} + onClick={() => this.setState({ displayedTab: SEARCH_TAB })} + > + { this.context.t('search') } + </div> + <div + className={classnames('page-container__tab', { + 'page-container__tab--selected': displayedTab === CUSTOM_TOKEN_TAB, + })} + onClick={() => this.setState({ displayedTab: CUSTOM_TOKEN_TAB })} + > + { this.context.t('customToken') } + </div> + </div> + </div> + <div className="page-container__content"> + { + displayedTab === CUSTOM_TOKEN_TAB + ? this.renderCustomTokenForm() + : this.renderSearchToken() + } + </div> + <div className="page-container__footer"> + <Button + type="secondary" + large + className="page-container__footer-button" + onClick={() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }} + > + { this.context.t('cancel') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => this.handleNext()} + disabled={this.hasError() || !this.hasSelected()} + > + { this.context.t('next') } + </Button> + </div> + </div> + ) + } +} + +export default AddToken diff --git a/ui/app/components/pages/add-token/add-token.container.js b/ui/app/components/pages/add-token/add-token.container.js new file mode 100644 index 000000000..87671b156 --- /dev/null +++ b/ui/app/components/pages/add-token/add-token.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import AddToken from './add-token.component' + +const { setPendingTokens, clearPendingTokens } = require('../../../actions') + +const mapStateToProps = ({ metamask }) => { + const { identities, tokens, pendingTokens } = metamask + return { + identities, + tokens, + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + setPendingTokens: tokens => dispatch(setPendingTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(AddToken) diff --git a/ui/app/components/pages/add-token/index.js b/ui/app/components/pages/add-token/index.js new file mode 100644 index 000000000..3666cae82 --- /dev/null +++ b/ui/app/components/pages/add-token/index.js @@ -0,0 +1,2 @@ +import AddToken from './add-token.container' +module.exports = AddToken diff --git a/ui/app/components/pages/add-token/index.scss b/ui/app/components/pages/add-token/index.scss new file mode 100644 index 000000000..39e86b97b --- /dev/null +++ b/ui/app/components/pages/add-token/index.scss @@ -0,0 +1,25 @@ +@import './token-list/index'; + +.add-token { + &__custom-token-form { + padding: 8px 16px 16px; + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + } + + &__search-token { + padding: 16px; + } + + &__token-list { + margin-top: 16px; + } +} diff --git a/ui/app/components/pages/add-token/token-list/index.js b/ui/app/components/pages/add-token/token-list/index.js new file mode 100644 index 000000000..21dd5ac72 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/index.js @@ -0,0 +1,2 @@ +import TokenList from './token-list.container' +module.exports = TokenList diff --git a/ui/app/components/pages/add-token/token-list/index.scss b/ui/app/components/pages/add-token/token-list/index.scss new file mode 100644 index 000000000..e32739d59 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/index.scss @@ -0,0 +1,65 @@ +@import './token-list-placeholder/index'; + +.token-list { + &__title { + font-size: .75rem; + } + + &__tokens-container { + display: flex; + flex-direction: column; + } + + &__token { + transition: 200ms ease-in-out; + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 8px; + margin-top: 8px; + box-sizing: border-box; + border-radius: 10px; + cursor: pointer; + border: 2px solid transparent; + position: relative; + + &:hover { + border: 2px solid rgba($malibu-blue, .5); + } + + &--selected { + border: 2px solid $malibu-blue !important; + } + + &--disabled { + opacity: .4; + pointer-events: none; + } + } + + &__token-icon { + width: 48px; + height: 48px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border-radius: 50%; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .24); + margin-right: 12px; + flex: 0 0 auto; + } + + &__token-data { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + } + + &__token-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js new file mode 100644 index 000000000..b82f45e93 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js @@ -0,0 +1,2 @@ +import TokenListPlaceholder from './token-list-placeholder.component' +module.exports = TokenListPlaceholder diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss new file mode 100644 index 000000000..9d0f4be32 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss @@ -0,0 +1,19 @@ +.token-list-placeholder { + display: flex; + align-items: center; + padding-top: 36px; + flex-direction: column; + line-height: 22px; + opacity: .5; + + &__text { + color: $silver-chalice; + width: 50%; + text-align: center; + margin-top: 8px; + } + + &__link { + color: $curious-blue; + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js b/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js new file mode 100644 index 000000000..abd599b26 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class TokenListPlaceholder extends Component { + static contextTypes = { + t: PropTypes.func, + } + + render () { + return ( + <div className="token-list-placeholder"> + <img src="images/tokensearch.svg" /> + <div className="token-list-placeholder__text"> + { this.context.t('addAcquiredTokens') } + </div> + <a + className="token-list-placeholder__link" + href="http://metamask.helpscoutdocs.com/article/16-managing-erc20-tokens" + target="_blank" + rel="noopener noreferrer" + > + { this.context.t('learnMore') } + </a> + </div> + ) + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list.component.js b/ui/app/components/pages/add-token/token-list/token-list.component.js new file mode 100644 index 000000000..724a68d6e --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list.component.js @@ -0,0 +1,60 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { checkExistingAddresses } from '../util' +import TokenListPlaceholder from './token-list-placeholder' + +export default class InfoBox extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + tokens: PropTypes.array, + results: PropTypes.array, + selectedTokens: PropTypes.object, + onToggleToken: PropTypes.func, + } + + render () { + const { results = [], selectedTokens = {}, onToggleToken, tokens = [] } = this.props + + return results.length === 0 + ? <TokenListPlaceholder /> + : ( + <div className="token-list"> + <div className="token-list__title"> + { this.context.t('searchResults') } + </div> + <div className="token-list__tokens-container"> + { + Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name, address } = results[i] || {} + const tokenAlreadyAdded = checkExistingAddresses(address, tokens) + + return Boolean(logo || symbol || name) && ( + <div + className={classnames('token-list__token', { + 'token-list__token--selected': selectedTokens[address], + 'token-list__token--disabled': tokenAlreadyAdded, + })} + onClick={() => !tokenAlreadyAdded && onToggleToken(results[i])} + key={i} + > + <div + className="token-list__token-icon" + style={{ backgroundImage: logo && `url(images/contract/${logo})` }}> + </div> + <div className="token-list__token-data"> + <span className="token-list__token-name">{ `${name} (${symbol})` }</span> + </div> + </div> + ) + }) + } + </div> + </div> + ) + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list.container.js b/ui/app/components/pages/add-token/token-list/token-list.container.js new file mode 100644 index 000000000..cd7b07a37 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import TokenList from './token-list.component' + +const mapStateToProps = ({ metamask }) => { + const { tokens } = metamask + return { + tokens, + } +} + +export default connect(mapStateToProps)(TokenList) diff --git a/ui/app/components/pages/add-token/token-search/index.js b/ui/app/components/pages/add-token/token-search/index.js new file mode 100644 index 000000000..acaa6b084 --- /dev/null +++ b/ui/app/components/pages/add-token/token-search/index.js @@ -0,0 +1,2 @@ +import TokenSearch from './token-search.component' +module.exports = TokenSearch diff --git a/ui/app/components/pages/add-token/token-search/token-search.component.js b/ui/app/components/pages/add-token/token-search/token-search.component.js new file mode 100644 index 000000000..036b2db1e --- /dev/null +++ b/ui/app/components/pages/add-token/token-search/token-search.component.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import contractMap from 'eth-contract-metadata' +import Fuse from 'fuse.js' +import InputAdornment from '@material-ui/core/InputAdornment' +import TextField from '../../../text-field' + +const contractList = Object.entries(contractMap) + .map(([ _, tokenData]) => tokenData) + .filter(tokenData => Boolean(tokenData.erc20)) + +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], +}) + +export default class TokenSearch extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + error: null, + } + + static propTypes = { + onSearch: PropTypes.func, + error: PropTypes.string, + } + + constructor (props) { + super(props) + + this.state = { + searchQuery: '', + } + } + + handleSearch (searchQuery) { + this.setState({ searchQuery }) + const fuseSearchResult = fuse.search(searchQuery) + const addressSearchResult = contractList.filter(token => { + return token.address.toLowerCase() === searchQuery.toLowerCase() + }) + const results = [...addressSearchResult, ...fuseSearchResult] + this.props.onSearch({ searchQuery, results }) + } + + renderAdornment () { + return ( + <InputAdornment + position="start" + style={{ marginRight: '12px' }} + > + <img src="images/search.svg" /> + </InputAdornment> + ) + } + + render () { + const { error } = this.props + const { searchQuery } = this.state + + return ( + <TextField + id="search-tokens" + placeholder={this.context.t('searchTokens')} + type="text" + value={searchQuery} + onChange={e => this.handleSearch(e.target.value)} + error={error} + fullWidth + startAdornment={this.renderAdornment()} + /> + ) + } +} diff --git a/ui/app/components/pages/add-token/util.js b/ui/app/components/pages/add-token/util.js new file mode 100644 index 000000000..579c56cc0 --- /dev/null +++ b/ui/app/components/pages/add-token/util.js @@ -0,0 +1,13 @@ +import R from 'ramda' + +export function checkExistingAddresses (address, tokenList = []) { + if (!address) { + return false + } + + const matchesAddress = existingToken => { + return existingToken.address.toLowerCase() === address.toLowerCase() + } + + return R.any(matchesAddress)(tokenList) +} |