aboutsummaryrefslogtreecommitdiffstats
path: root/ui/app/components/pages
diff options
context:
space:
mode:
Diffstat (limited to 'ui/app/components/pages')
-rw-r--r--ui/app/components/pages/add-token.js431
-rw-r--r--ui/app/components/pages/add-token/add-token.component.js356
-rw-r--r--ui/app/components/pages/add-token/add-token.container.js22
-rw-r--r--ui/app/components/pages/add-token/index.js2
-rw-r--r--ui/app/components/pages/add-token/index.scss25
-rw-r--r--ui/app/components/pages/add-token/token-list/index.js2
-rw-r--r--ui/app/components/pages/add-token/token-list/index.scss65
-rw-r--r--ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js2
-rw-r--r--ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss19
-rw-r--r--ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js27
-rw-r--r--ui/app/components/pages/add-token/token-list/token-list.component.js60
-rw-r--r--ui/app/components/pages/add-token/token-list/token-list.container.js11
-rw-r--r--ui/app/components/pages/add-token/token-search/index.js2
-rw-r--r--ui/app/components/pages/add-token/token-search/token-search.component.js85
-rw-r--r--ui/app/components/pages/add-token/util.js13
-rw-r--r--ui/app/components/pages/confirm-add-token/confirm-add-token.component.js115
-rw-r--r--ui/app/components/pages/confirm-add-token/confirm-add-token.container.js20
-rw-r--r--ui/app/components/pages/confirm-add-token/index.js2
-rw-r--r--ui/app/components/pages/confirm-add-token/index.scss69
-rw-r--r--ui/app/components/pages/confirm-add-token/token-balance/index.js2
-rw-r--r--ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js16
-rw-r--r--ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js16
-rw-r--r--ui/app/components/pages/index.scss5
-rw-r--r--ui/app/components/pages/unlock-page/index.scss (renamed from ui/app/components/pages/unlock-page/unlock-page.scss)0
-rw-r--r--ui/app/components/pages/unlock-page/unlock-page.component.js3
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>