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 | |
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')
25 files changed, 938 insertions, 432 deletions
diff --git a/ui/app/components/pages/add-token.js b/ui/app/components/pages/add-token.js deleted file mode 100644 index 8d52571d0..000000000 --- a/ui/app/components/pages/add-token.js +++ /dev/null @@ -1,431 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const classnames = require('classnames') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const R = require('ramda') -const Fuse = require('fuse.js') -const contractMap = require('eth-contract-metadata') -const TokenBalance = require('../../components/token-balance') -const Identicon = require('../../components/identicon') -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 }, - ], -}) -const actions = require('../../actions') -const ethUtil = require('ethereumjs-util') -const { tokenInfoGetter } = require('../../token-util') -const { DEFAULT_ROUTE } = require('../../routes') - -const emptyAddr = '0x0000000000000000000000000000000000000000' - -AddTokenScreen.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(AddTokenScreen) - - -function mapStateToProps (state) { - const { identities, tokens } = state.metamask - return { - identities, - tokens, - } -} - -function mapDispatchToProps (dispatch) { - return { - addTokens: tokens => dispatch(actions.addTokens(tokens)), - } -} - -inherits(AddTokenScreen, Component) -function AddTokenScreen () { - this.state = { - isShowingConfirmation: false, - isShowingInfoBox: true, - customAddress: '', - customSymbol: '', - customDecimals: '', - searchQuery: '', - selectedTokens: {}, - errors: {}, - autoFilled: false, - displayedTab: 'SEARCH', - } - this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) - this.tokenSymbolDidChange = this.tokenSymbolDidChange.bind(this) - this.tokenDecimalsDidChange = this.tokenDecimalsDidChange.bind(this) - this.onNext = this.onNext.bind(this) - Component.call(this) -} - -AddTokenScreen.prototype.componentWillMount = function () { - this.tokenInfoGetter = tokenInfoGetter() -} - -AddTokenScreen.prototype.toggleToken = function (address, token) { - const { selectedTokens = {}, errors } = this.state - const selectedTokensCopy = { ...selectedTokens } - - if (address in selectedTokensCopy) { - delete selectedTokensCopy[address] - } else { - selectedTokensCopy[address] = token - } - - this.setState({ - selectedTokens: selectedTokensCopy, - errors: { - ...errors, - tokenSelector: null, - }, - }) -} - -AddTokenScreen.prototype.onNext = function () { - const { isValid, errors } = this.validate() - - return !isValid - ? this.setState({ errors }) - : this.setState({ isShowingConfirmation: true }) -} - -AddTokenScreen.prototype.tokenAddressDidChange = function (e) { - const customAddress = e.target.value.trim() - this.setState({ customAddress }) - if (ethUtil.isValidAddress(customAddress) && customAddress !== emptyAddr) { - this.attemptToAutoFillTokenParams(customAddress) - } else { - this.setState({ - customSymbol: '', - customDecimals: 0, - }) - } -} - -AddTokenScreen.prototype.tokenSymbolDidChange = function (e) { - const customSymbol = e.target.value.trim() - this.setState({ customSymbol }) -} - -AddTokenScreen.prototype.tokenDecimalsDidChange = function (e) { - const customDecimals = e.target.value.trim() - this.setState({ customDecimals }) -} - -AddTokenScreen.prototype.checkExistingAddresses = function (address) { - if (!address) return false - const tokensList = this.props.tokens - const matchesAddress = existingToken => { - return existingToken.address.toLowerCase() === address.toLowerCase() - } - - return R.any(matchesAddress)(tokensList) -} - -AddTokenScreen.prototype.validate = function () { - const errors = {} - const identitiesList = Object.keys(this.props.identities) - const { customAddress, customSymbol, customDecimals, selectedTokens } = this.state - const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() - - if (customAddress) { - const validAddress = ethUtil.isValidAddress(customAddress) - if (!validAddress) { - errors.customAddress = this.context.t('invalidAddress') - } - - const validDecimals = customDecimals !== null - && customDecimals !== '' - && customDecimals >= 0 - && customDecimals < 36 - if (!validDecimals) { - errors.customDecimals = this.context.t('decimalsMustZerotoTen') - } - - const symbolLen = customSymbol.trim().length - const validSymbol = symbolLen > 0 && symbolLen < 10 - if (!validSymbol) { - errors.customSymbol = this.context.t('symbolBetweenZeroTen') - } - - const ownAddress = identitiesList.includes(standardAddress) - if (ownAddress) { - errors.customAddress = this.context.t('personalAddressDetected') - } - - const tokenAlreadyAdded = this.checkExistingAddresses(customAddress) - if (tokenAlreadyAdded) { - errors.customAddress = this.context.t('tokenAlreadyAdded') - } - } else if ( - Object.entries(selectedTokens) - .reduce((isEmpty, [ symbol, isSelected ]) => ( - isEmpty && !isSelected - ), true) - ) { - errors.tokenSelector = this.context.t('mustSelectOne') - } - - return { - isValid: !Object.keys(errors).length, - errors, - } -} - -AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { - const { symbol, decimals } = await this.tokenInfoGetter(address) - if (symbol && decimals) { - this.setState({ - customSymbol: symbol, - customDecimals: decimals, - autoFilled: true, - }) - } -} - -AddTokenScreen.prototype.renderCustomForm = function () { - const { autoFilled, customAddress, customSymbol, customDecimals, errors } = this.state - - return ( - h('div.add-token__add-custom-form', [ - h('div', { - className: classnames('add-token__add-custom-field', { - 'add-token__add-custom-field--error': errors.customAddress, - }), - }, [ - h('div.add-token__add-custom-label', this.context.t('tokenAddress')), - h('input.add-token__add-custom-input', { - type: 'text', - onChange: this.tokenAddressDidChange, - value: customAddress, - }), - h('div.add-token__add-custom-error-message', errors.customAddress), - ]), - h('div', { - className: classnames('add-token__add-custom-field', { - 'add-token__add-custom-field--error': errors.customSymbol, - }), - }, [ - h('div.add-token__add-custom-label', this.context.t('tokenSymbol')), - h('input.add-token__add-custom-input', { - type: 'text', - onChange: this.tokenSymbolDidChange, - value: customSymbol, - disabled: autoFilled, - }), - h('div.add-token__add-custom-error-message', errors.customSymbol), - ]), - h('div', { - className: classnames('add-token__add-custom-field', { - 'add-token__add-custom-field--error': errors.customDecimals, - }), - }, [ - h('div.add-token__add-custom-label', this.context.t('decimal')), - h('input.add-token__add-custom-input', { - type: 'number', - onChange: this.tokenDecimalsDidChange, - value: customDecimals, - disabled: autoFilled, - }), - h('div.add-token__add-custom-error-message', errors.customDecimals), - ]), - ]) - ) -} - -AddTokenScreen.prototype.renderTokenList = function () { - const { searchQuery = '', selectedTokens } = this.state - const fuseSearchResult = fuse.search(searchQuery) - const addressSearchResult = contractList.filter(token => { - return token.address.toLowerCase() === searchQuery.toLowerCase() - }) - const results = [...addressSearchResult, ...fuseSearchResult] - - return h('div', [ - results.length > 0 && h('div.add-token__token-icons-title', this.context.t('popularTokens')), - h('div.add-token__token-icons-container', Array(6).fill(undefined) - .map((_, i) => { - const { logo, symbol, name, address } = results[i] || {} - const tokenAlreadyAdded = this.checkExistingAddresses(address) - return Boolean(logo || symbol || name) && ( - h('div.add-token__token-wrapper', { - className: classnames({ - 'add-token__token-wrapper--selected': selectedTokens[address], - 'add-token__token-wrapper--disabled': tokenAlreadyAdded, - }), - onClick: () => !tokenAlreadyAdded && this.toggleToken(address, results[i]), - }, [ - h('div.add-token__token-icon', { - style: { - backgroundImage: logo && `url(images/contract/${logo})`, - }, - }), - h('div.add-token__token-data', [ - h('div.add-token__token-symbol', symbol), - h('div.add-token__token-name', name), - ]), - // tokenAlreadyAdded && ( - // h('div.add-token__token-message', 'Already added') - // ), - ]) - ) - })), - ]) -} - -AddTokenScreen.prototype.renderConfirmation = function () { - const { - customAddress: address, - customSymbol: symbol, - customDecimals: decimals, - selectedTokens, - } = this.state - - const { addTokens, history } = this.props - - const customToken = { - address, - symbol, - decimals, - } - - const tokens = address && symbol && decimals - ? { ...selectedTokens, [address]: customToken } - : selectedTokens - - return ( - h('div.add-token', [ - h('div.add-token__wrapper', [ - h('div.add-token__content-container.add-token__confirmation-content', [ - h('div.add-token__description.add-token__confirmation-description', this.context.t('balances')), - h('div.add-token__confirmation-token-list', - Object.entries(tokens) - .map(([ address, token ]) => ( - h('span.add-token__confirmation-token-list-item', [ - h(Identicon, { - className: 'add-token__confirmation-token-icon', - diameter: 75, - address, - }), - h(TokenBalance, { token }), - ]) - )) - ), - ]), - ]), - h('div.add-token__buttons', [ - h('button.btn-secondary--lg.add-token__cancel-button', { - onClick: () => this.setState({ isShowingConfirmation: false }), - }, this.context.t('back')), - h('button.btn-primary--lg', { - onClick: () => addTokens(tokens).then(() => history.push(DEFAULT_ROUTE)), - }, this.context.t('addTokens')), - ]), - ]) - ) -} - -AddTokenScreen.prototype.displayTab = function (selectedTab) { - this.setState({ displayedTab: selectedTab }) -} - -AddTokenScreen.prototype.renderTabs = function () { - const { isShowingInfoBox, displayedTab, errors } = this.state - - return displayedTab === 'CUSTOM_TOKEN' - ? this.renderCustomForm() - : h('div', [ - h('div.add-token__wrapper', [ - h('div.add-token__content-container', [ - isShowingInfoBox && h('div.add-token__info-box', [ - h('div.add-token__info-box__close', { - onClick: () => this.setState({ isShowingInfoBox: false }), - }), - h('div.add-token__info-box__title', this.context.t('whatsThis')), - h('div.add-token__info-box__copy', this.context.t('keepTrackTokens')), - h('a.add-token__info-box__copy--blue', { - href: 'http://metamask.helpscoutdocs.com/article/16-managing-erc20-tokens', - target: '_blank', - }, this.context.t('learnMore')), - ]), - h('div.add-token__input-container', [ - h('input.add-token__input', { - type: 'text', - placeholder: this.context.t('searchTokens'), - onChange: e => this.setState({ searchQuery: e.target.value }), - }), - h('div.add-token__search-input-error-message', errors.tokenSelector), - ]), - this.renderTokenList(), - ]), - ]), - ]) -} - -AddTokenScreen.prototype.render = function () { - const { - isShowingConfirmation, - displayedTab, - } = this.state - const { history } = this.props - - return h('div.add-token', [ - h('div.add-token__header', [ - h('div.add-token__header__cancel', { - onClick: () => history.push(DEFAULT_ROUTE), - }, [ - h('i.fa.fa-angle-left.fa-lg'), - h('span', this.context.t('cancel')), - ]), - h('div.add-token__header__title', this.context.t('addTokens')), - isShowingConfirmation && h('div.add-token__header__subtitle', this.context.t('likeToAddTokens')), - !isShowingConfirmation && h('div.add-token__header__tabs', [ - - h('div.add-token__header__tabs__tab', { - className: classnames('add-token__header__tabs__tab', { - 'add-token__header__tabs__selected': displayedTab === 'SEARCH', - 'add-token__header__tabs__unselected': displayedTab !== 'SEARCH', - }), - onClick: () => this.displayTab('SEARCH'), - }, this.context.t('search')), - - h('div.add-token__header__tabs__tab', { - className: classnames('add-token__header__tabs__tab', { - 'add-token__header__tabs__selected': displayedTab === 'CUSTOM_TOKEN', - 'add-token__header__tabs__unselected': displayedTab !== 'CUSTOM_TOKEN', - }), - onClick: () => this.displayTab('CUSTOM_TOKEN'), - }, this.context.t('customToken')), - - ]), - ]), - - isShowingConfirmation - ? this.renderConfirmation() - : this.renderTabs(), - - !isShowingConfirmation && h('div.add-token__buttons', [ - h('button.btn-secondary--lg.add-token__cancel-button', { - onClick: () => history.push(DEFAULT_ROUTE), - }, this.context.t('cancel')), - h('button.btn-primary--lg.add-token__confirm-button', { - onClick: this.onNext, - }, this.context.t('next')), - ]), - ]) -} 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) +} diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js new file mode 100644 index 000000000..9db9efc37 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js @@ -0,0 +1,115 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { DEFAULT_ROUTE, ADD_TOKEN_ROUTE } from '../../../routes' +import Button from '../../button' +import Identicon from '../../../components/identicon' +import TokenBalance from './token-balance' + +export default class ConfirmAddToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + clearPendingTokens: PropTypes.func, + addTokens: PropTypes.func, + pendingTokens: PropTypes.object, + } + + componentDidMount () { + const { pendingTokens = {}, history } = this.props + + if (Object.keys(pendingTokens).length === 0) { + history.push(DEFAULT_ROUTE) + } + } + + getTokenName (name, symbol) { + return typeof name === 'undefined' + ? symbol + : `${name} (${symbol})` + } + + render () { + const { history, addTokens, clearPendingTokens, pendingTokens } = this.props + + return ( + <div className="page-container"> + <div className="page-container__header"> + <div className="page-container__title"> + { this.context.t('addTokens') } + </div> + <div className="page-container__subtitle"> + { this.context.t('likeToAddTokens') } + </div> + </div> + <div className="page-container__content"> + <div className="confirm-add-token"> + <div className="confirm-add-token__header"> + <div className="confirm-add-token__token"> + { this.context.t('token') } + </div> + <div className="confirm-add-token__balance"> + { this.context.t('balance') } + </div> + </div> + <div className="confirm-add-token__token-list"> + { + Object.entries(pendingTokens) + .map(([ address, token ]) => { + const { name, symbol } = token + + return ( + <div + className="confirm-add-token__token-list-item" + key={address} + > + <div className="confirm-add-token__token confirm-add-token__data"> + <Identicon + className="confirm-add-token__token-icon" + diameter={48} + address={address} + /> + <div className="confirm-add-token__name"> + { this.getTokenName(name, symbol) } + </div> + </div> + <div className="confirm-add-token__balance"> + <TokenBalance token={token} /> + </div> + </div> + ) + }) + } + </div> + </div> + </div> + <div className="page-container__footer"> + <Button + type="secondary" + large + className="page-container__footer-button" + onClick={() => history.push(ADD_TOKEN_ROUTE)} + > + { this.context.t('back') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => { + addTokens(pendingTokens) + .then(() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }) + }} + > + { this.context.t('addTokens') } + </Button> + </div> + </div> + ) + } +} diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js new file mode 100644 index 000000000..0190024d9 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmAddToken from './confirm-add-token.component' + +const { addTokens, clearPendingTokens } = require('../../../actions') + +const mapStateToProps = ({ metamask }) => { + const { pendingTokens } = metamask + return { + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + addTokens: tokens => dispatch(addTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ConfirmAddToken) diff --git a/ui/app/components/pages/confirm-add-token/index.js b/ui/app/components/pages/confirm-add-token/index.js new file mode 100644 index 000000000..b7decabec --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/index.js @@ -0,0 +1,2 @@ +import ConfirmAddToken from './confirm-add-token.container' +module.exports = ConfirmAddToken diff --git a/ui/app/components/pages/confirm-add-token/index.scss b/ui/app/components/pages/confirm-add-token/index.scss new file mode 100644 index 000000000..66146cf78 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/index.scss @@ -0,0 +1,69 @@ +.confirm-add-token { + padding: 16px; + + &__header { + font-size: .75rem; + display: flex; + } + + &__token { + flex: 1; + min-width: 0; + } + + &__balance { + flex: 0 0 30%; + min-width: 0; + } + + &__token-list { + display: flex; + flex-flow: column nowrap; + + .token-balance { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + + &__amount { + color: $scorpion; + font-size: 43px; + line-height: 43px; + margin-right: 8px; + } + + &__symbol { + color: $scorpion; + font-size: 16px; + font-weight: 400; + line-height: 24px; + } + } + } + + &__token-list-item { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin-top: 8px; + box-sizing: border-box; + } + + &__data { + display: flex; + align-items: center; + padding: 8px; + } + + &__name { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__token-icon { + margin-right: 12px; + flex: 0 0 auto; + } +} diff --git a/ui/app/components/pages/confirm-add-token/token-balance/index.js b/ui/app/components/pages/confirm-add-token/token-balance/index.js new file mode 100644 index 000000000..6fb5c8223 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/index.js @@ -0,0 +1,2 @@ +import TokenBalance from './token-balance.container' +module.exports = TokenBalance diff --git a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js new file mode 100644 index 000000000..976788d4c --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class TokenBalance extends Component { + static propTypes = { + string: PropTypes.string, + symbol: PropTypes.string, + error: PropTypes.string, + } + + render () { + return ( + <div className="hide-text-overflow">{ this.props.string }</div> + ) + } +} diff --git a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js new file mode 100644 index 000000000..bc1289ce1 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import withTokenTracker from '../../../../helpers/with-token-tracker' +import TokenBalance from './token-balance.component' +import selectors from '../../../../selectors' + +const mapStateToProps = state => { + return { + userAddress: selectors.getSelectedAddress(state), + } +} + +export default compose( + connect(mapStateToProps), + withTokenTracker +)(TokenBalance) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss new file mode 100644 index 000000000..b15c59863 --- /dev/null +++ b/ui/app/components/pages/index.scss @@ -0,0 +1,5 @@ +@import './unlock-page/index'; + +@import './add-token/index'; + +@import './confirm-add-token/index'; diff --git a/ui/app/components/pages/unlock-page/unlock-page.scss b/ui/app/components/pages/unlock-page/index.scss index 3d44bd037..3d44bd037 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.scss +++ b/ui/app/components/pages/unlock-page/index.scss diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js index 0976d9506..a2f009d8f 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import Button from 'material-ui/Button' +import Button from '@material-ui/core/Button' import TextField from '../../text-field' const { ENVIRONMENT_TYPE_POPUP } = require('../../../../../app/scripts/lib/enums') @@ -129,6 +129,7 @@ class UnlockPage extends Component { error={error} autoFocus autoComplete="current-password" + material fullWidth /> </form> |