Merge pull request #4308 from MetaMask/i4232-addtoken
Update designs for Add Token screenfeature/default_network_editable
commit
b5bbfd3264
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 5.5 KiB |
@ -1,2 +1,2 @@ |
||||
const Button = require('./button.component') |
||||
import Button from './button.component' |
||||
module.exports = Button |
||||
|
@ -0,0 +1,5 @@ |
||||
@import './export-text-container/index'; |
||||
|
||||
@import './info-box/index'; |
||||
|
||||
@import './pages/index'; |
@ -0,0 +1,2 @@ |
||||
import InfoBox from './info-box.component' |
||||
module.exports = InfoBox |
@ -0,0 +1,24 @@ |
||||
.info-box { |
||||
border-radius: 4px; |
||||
background-color: $alabaster; |
||||
position: relative; |
||||
padding: 16px; |
||||
display: flex; |
||||
flex-flow: column; |
||||
color: $mid-gray; |
||||
|
||||
&__close::after { |
||||
content: '\00D7'; |
||||
font-size: 29px; |
||||
font-weight: 200; |
||||
color: $dusty-gray; |
||||
position: absolute; |
||||
right: 12px; |
||||
top: 0; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__description { |
||||
font-size: .75rem; |
||||
} |
||||
} |
@ -0,0 +1,49 @@ |
||||
import React, { Component } from 'react' |
||||
import PropTypes from 'prop-types' |
||||
|
||||
export default class InfoBox extends Component { |
||||
static contextTypes = { |
||||
t: PropTypes.func, |
||||
} |
||||
|
||||
static propTypes = { |
||||
onClose: PropTypes.func, |
||||
title: PropTypes.string, |
||||
description: PropTypes.string, |
||||
} |
||||
|
||||
constructor (props) { |
||||
super(props) |
||||
|
||||
this.state = { |
||||
isShowing: true, |
||||
} |
||||
} |
||||
|
||||
handleClose () { |
||||
const { onClose } = this.props |
||||
|
||||
if (onClose) { |
||||
onClose() |
||||
} else { |
||||
this.setState({ isShowing: false }) |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { title, description } = this.props |
||||
|
||||
return !this.state.isShowing |
||||
? null |
||||
: ( |
||||
<div className="info-box"> |
||||
<div |
||||
className="info-box__close" |
||||
onClick={() => this.handleClose()} |
||||
/> |
||||
<div className="info-box__title">{ title }</div> |
||||
<div className="info-box__description">{ description }</div> |
||||
</div> |
||||
) |
||||
} |
||||
} |
@ -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')), |
||||
]), |
||||
]) |
||||
} |
@ -0,0 +1,351 @@ |
||||
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 = 0 } = await this.tokenInfoGetter(address) |
||||
|
||||
const autoFilled = Boolean(symbol && decimals) |
||||
this.setState({ autoFilled }) |
||||
this.handleCustomSymbolChange(symbol || '') |
||||
this.handleCustomDecimalsChange(decimals) |
||||
} |
||||
|
||||
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 |
@ -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) |
@ -0,0 +1,2 @@ |
||||
import AddToken from './add-token.container' |
||||
module.exports = AddToken |
@ -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; |
||||
} |
||||
} |
@ -0,0 +1,2 @@ |
||||
import TokenList from './token-list.container' |
||||
module.exports = TokenList |
@ -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; |
||||
} |
||||
} |
@ -0,0 +1,2 @@ |
||||
import TokenListPlaceholder from './token-list-placeholder.component' |
||||
module.exports = TokenListPlaceholder |
@ -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; |
||||
} |
||||
} |
@ -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> |
||||
) |
||||
} |
||||
} |
@ -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> |
||||
) |
||||
} |
||||
} |
@ -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) |
@ -0,0 +1,2 @@ |
||||
import TokenSearch from './token-search.component' |
||||
module.exports = TokenSearch |
@ -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()} |
||||
/> |
||||
) |
||||
} |
||||
} |
@ -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) |
||||
} |
@ -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> |
||||
) |
||||
} |
||||
} |
@ -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) |
@ -0,0 +1,2 @@ |
||||
import ConfirmAddToken from './confirm-add-token.container' |
||||
module.exports = ConfirmAddToken |
@ -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; |
||||
} |
||||
} |
@ -0,0 +1,2 @@ |
||||
import TokenBalance from './token-balance.container' |
||||
module.exports = TokenBalance |
@ -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> |
||||
) |
||||
} |
||||
} |
@ -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) |
@ -0,0 +1,5 @@ |
||||
@import './unlock-page/index'; |
||||
|
||||
@import './add-token/index'; |
||||
|
||||
@import './confirm-add-token/index'; |
@ -1,59 +1,102 @@ |
||||
import React from 'react' |
||||
import React, { Component } from 'react' |
||||
import PropTypes from 'prop-types' |
||||
import { withStyles } from 'material-ui/styles' |
||||
import { default as MaterialTextField } from 'material-ui/TextField' |
||||
import { withStyles } from '@material-ui/core/styles' |
||||
import { default as MaterialTextField } from '@material-ui/core/TextField' |
||||
|
||||
const styles = { |
||||
cssLabel: { |
||||
'&$cssFocused': { |
||||
materialLabel: { |
||||
'&$materialFocused': { |
||||
color: '#aeaeae', |
||||
}, |
||||
'&$cssError': { |
||||
'&$materialError': { |
||||
color: '#aeaeae', |
||||
}, |
||||
fontWeight: '400', |
||||
color: '#aeaeae', |
||||
}, |
||||
cssFocused: {}, |
||||
cssUnderline: { |
||||
materialFocused: {}, |
||||
materialUnderline: { |
||||
'&:after': { |
||||
backgroundColor: '#f7861c', |
||||
borderBottom: '2px solid #f7861c', |
||||
}, |
||||
}, |
||||
cssError: {}, |
||||
materialError: {}, |
||||
// Non-material styles
|
||||
formLabel: { |
||||
'&$formLabelFocused': { |
||||
color: '#5b5b5b', |
||||
}, |
||||
'&$materialError': { |
||||
color: '#5b5b5b', |
||||
}, |
||||
}, |
||||
formLabelFocused: {}, |
||||
inputFocused: {}, |
||||
inputRoot: { |
||||
'label + &': { |
||||
marginTop: '8px', |
||||
}, |
||||
border: '1px solid #d2d8dd', |
||||
height: '48px', |
||||
borderRadius: '4px', |
||||
padding: '0 16px', |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
'&$inputFocused': { |
||||
border: '1px solid #2f9ae0', |
||||
}, |
||||
}, |
||||
inputLabel: { |
||||
fontSize: '.75rem', |
||||
transform: 'none', |
||||
transition: 'none', |
||||
position: 'initial', |
||||
color: '#5b5b5b', |
||||
}, |
||||
} |
||||
|
||||
const TextField = props => { |
||||
const { error, classes, ...textFieldProps } = props |
||||
class TextField extends Component { |
||||
static defaultProps = { |
||||
error: null, |
||||
} |
||||
|
||||
return ( |
||||
<MaterialTextField |
||||
error={Boolean(error)} |
||||
helperText={error} |
||||
InputLabelProps={{ |
||||
FormLabelClasses: { |
||||
root: classes.cssLabel, |
||||
focused: classes.cssFocused, |
||||
error: classes.cssError, |
||||
}, |
||||
}} |
||||
InputProps={{ |
||||
classes: { |
||||
underline: classes.cssUnderline, |
||||
}, |
||||
}} |
||||
{...textFieldProps} |
||||
/> |
||||
) |
||||
} |
||||
static propTypes = { |
||||
error: PropTypes.string, |
||||
classes: PropTypes.object, |
||||
material: PropTypes.bool, |
||||
startAdornment: PropTypes.element, |
||||
} |
||||
|
||||
TextField.defaultProps = { |
||||
error: null, |
||||
} |
||||
render () { |
||||
const { error, classes, material, startAdornment, ...textFieldProps } = this.props |
||||
|
||||
TextField.propTypes = { |
||||
error: PropTypes.string, |
||||
classes: PropTypes.object, |
||||
return ( |
||||
<MaterialTextField |
||||
error={Boolean(error)} |
||||
helperText={error} |
||||
InputLabelProps={{ |
||||
shrink: material ? undefined : true, |
||||
className: material ? '' : classes.inputLabel, |
||||
FormLabelClasses: { |
||||
root: material ? classes.materialLabel : classes.formLabel, |
||||
focused: material ? classes.materialFocused : classes.formLabelFocused, |
||||
error: classes.materialError, |
||||
}, |
||||
}} |
||||
InputProps={{ |
||||
startAdornment: startAdornment || undefined, |
||||
disableUnderline: !material, |
||||
classes: { |
||||
root: material ? '' : classes.inputRoot, |
||||
input: material ? '' : classes.input, |
||||
underline: material ? classes.materialUnderline : '', |
||||
focused: material ? '' : classes.inputFocused, |
||||
}, |
||||
}} |
||||
{...textFieldProps} |
||||
/> |
||||
) |
||||
} |
||||
} |
||||
|
||||
export default withStyles(styles)(TextField) |
||||
|
@ -1,461 +0,0 @@ |
||||
.add-token { |
||||
width: 498px; |
||||
max-height: 805px; |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
position: relative; |
||||
z-index: 12; |
||||
font-family: 'Roboto'; |
||||
background: white; |
||||
border-radius: 8px; |
||||
box-shadow: 0 0 7px 0 rgba(0, 0, 0, 0.08); |
||||
|
||||
&__wrapper { |
||||
background-color: $white; |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
align-items: center; |
||||
flex: 0 0 auto; |
||||
} |
||||
|
||||
&__header { |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
padding: 20px 20px 0px; |
||||
border-bottom: 1px solid $geyser; |
||||
flex: 0 0 auto; |
||||
|
||||
&__cancel { |
||||
color: $dodger-blue; |
||||
display: flex; |
||||
align-items: center; |
||||
|
||||
span { |
||||
font-family: Roboto; |
||||
font-size: 16px; |
||||
font-weight: 400; |
||||
line-height: 21px; |
||||
margin-left: 8px; |
||||
cursor:pointer; |
||||
} |
||||
} |
||||
|
||||
&__title { |
||||
color: $tundora; |
||||
font-size: 32px; |
||||
font-weight: 500; |
||||
margin-top: 4px; |
||||
} |
||||
|
||||
&__subtitle { |
||||
font-weight: 400; |
||||
margin-top: 15px; |
||||
margin-bottom: 21px; |
||||
} |
||||
|
||||
&__tabs { |
||||
display: flex; |
||||
|
||||
&__tab { |
||||
height: 54px; |
||||
padding: 15px 10px; |
||||
color: $dusty-gray; |
||||
font-family: Roboto; |
||||
font-size: 18px; |
||||
font-weight: 400; |
||||
line-height: 24px; |
||||
text-align: center; |
||||
} |
||||
|
||||
&__tab:first-of-type { |
||||
margin-right: 20px; |
||||
} |
||||
|
||||
&__unselected:hover { |
||||
color: $black; |
||||
border-bottom: none; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__selected { |
||||
color: $curious-blue; |
||||
border-bottom: 3px solid $curious-blue; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__info-box { |
||||
height: 96px; |
||||
margin: 20px 20px 0px; |
||||
border-radius: 4px; |
||||
background-color: $alabaster; |
||||
position: relative; |
||||
padding-left: 18px; |
||||
display: flex; |
||||
flex-flow: column; |
||||
|
||||
&__close::after { |
||||
content: '\00D7'; |
||||
font-size: 29px; |
||||
font-weight: 200; |
||||
color: $dusty-gray; |
||||
position: absolute; |
||||
right: 17px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__title { |
||||
color: $mid-gray; |
||||
font-family: Roboto; |
||||
font-size: 14px; |
||||
font-weight: 400; |
||||
margin-top: 15px; |
||||
margin-bottom: 9px; |
||||
} |
||||
|
||||
&__copy, |
||||
&__copy--blue { |
||||
color: $mid-gray; |
||||
font-family: Roboto; |
||||
font-size: 12px; |
||||
font-weight: 400; |
||||
line-height: 18px; |
||||
} |
||||
|
||||
&__copy--blue { |
||||
color: $curious-blue; |
||||
} |
||||
} |
||||
|
||||
&__description { |
||||
text-align: center; |
||||
} |
||||
|
||||
&__description + &__description { |
||||
margin-top: 24px; |
||||
} |
||||
|
||||
&__confirmation-description { |
||||
font-weight: 400; |
||||
margin: 20px 0 40px 0; |
||||
} |
||||
|
||||
&__content-container { |
||||
width: 100%; |
||||
} |
||||
|
||||
&__input-container { |
||||
display: flex; |
||||
position: relative; |
||||
} |
||||
|
||||
&__search-input-error-message { |
||||
position: absolute; |
||||
bottom: -10px; |
||||
left: 22px; |
||||
font-size: 12px; |
||||
width: 100%; |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
white-space: nowrap; |
||||
color: $red; |
||||
} |
||||
|
||||
&__input, |
||||
&__add-custom-input { |
||||
height: 54px; |
||||
padding: 0px 20px; |
||||
border: 1px solid $geyser; |
||||
border-radius: 4px; |
||||
margin: 22px 24px; |
||||
position: relative; |
||||
flex: 1 0 auto; |
||||
color: $scorpion; |
||||
font-family: Roboto; |
||||
font-size: 16px; |
||||
|
||||
&::placeholder { |
||||
color: $scorpion; |
||||
font-family: Roboto; |
||||
font-size: 16px; |
||||
line-height: 21px; |
||||
} |
||||
} |
||||
|
||||
&__footers { |
||||
width: 100%; |
||||
} |
||||
|
||||
&__add-custom { |
||||
color: $scorpion; |
||||
font-size: 18px; |
||||
line-height: 24px; |
||||
text-align: center; |
||||
padding: 12px 0; |
||||
font-weight: 600; |
||||
cursor: pointer; |
||||
position: relative; |
||||
|
||||
&:hover { |
||||
background-color: rgba(0, 0, 0, .05); |
||||
} |
||||
|
||||
&:active { |
||||
background-color: rgba(0, 0, 0, .1); |
||||
} |
||||
|
||||
.fa { |
||||
position: absolute; |
||||
right: 24px; |
||||
font-size: 24px; |
||||
line-height: 24px; |
||||
} |
||||
} |
||||
|
||||
&__add-custom-form { |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
margin: 40px 0 30px; |
||||
} |
||||
|
||||
&__add-custom-field { |
||||
position: relative; |
||||
display: flex; |
||||
flex-flow: column; |
||||
flex: 1 0 auto; |
||||
|
||||
&--error { |
||||
.add-token__add-custom-input { |
||||
border-color: $red; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__add-custom-error-message { |
||||
position: absolute; |
||||
bottom: 1px; |
||||
left: 22px; |
||||
font-size: 12px; |
||||
width: 100%; |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
white-space: nowrap; |
||||
color: $red; |
||||
} |
||||
|
||||
&__add-custom-label { |
||||
font-size: 16px; |
||||
font-weight: 400; |
||||
line-height: 21px; |
||||
margin-left: 22px; |
||||
color: $scorpion; |
||||
} |
||||
|
||||
&__add-custom-input { |
||||
margin-top: 6px; |
||||
font-size: 16px; |
||||
|
||||
&::placeholder { |
||||
color: $silver; |
||||
font-size: 16px; |
||||
} |
||||
} |
||||
|
||||
&__add-custom-field + &__add-custom-field { |
||||
margin-top: 6px; |
||||
} |
||||
|
||||
&__buttons { |
||||
display: flex; |
||||
flex-flow: row nowrap; |
||||
flex: 0 0 auto; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding-bottom: 30px; |
||||
padding-top: 20px; |
||||
} |
||||
|
||||
&__confirm-button, |
||||
&__cancel-button { |
||||
margin: 0 12px; |
||||
padding: 10px 13px; |
||||
height: 54px; |
||||
width: 133px; |
||||
margin-right: 1.2rem; |
||||
} |
||||
|
||||
&__token-icons-title { |
||||
color: #5B5D67; |
||||
font-family: Roboto; |
||||
font-size: 18px; |
||||
font-weight: 400; |
||||
line-height: 24px; |
||||
margin-left: 24px; |
||||
margin-top: 8px; |
||||
margin-bottom: 20px; |
||||
} |
||||
|
||||
&__token-icons-container { |
||||
display: flex; |
||||
flex-flow: row wrap; |
||||
} |
||||
|
||||
&__token-wrapper { |
||||
transition: 200ms ease-in-out; |
||||
display: flex; |
||||
flex-flow: row nowrap; |
||||
flex: 0 0 42.5%; |
||||
align-items: center; |
||||
padding: 12px; |
||||
margin: 0% 2.5% 1.5%; |
||||
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-data { |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
&__token-name { |
||||
font-weight: 400; |
||||
font-size: 14px; |
||||
line-height: 19px; |
||||
} |
||||
|
||||
&__token-symbol { |
||||
font-size: 22px; |
||||
line-height: 29px; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
&__token-icon { |
||||
width: 60px; |
||||
height: 60px; |
||||
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-message { |
||||
position: absolute; |
||||
color: $caribbean-green; |
||||
font-size: 11px; |
||||
bottom: 0; |
||||
left: 85px; |
||||
} |
||||
|
||||
&__confirmation-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; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__confirmation-title { |
||||
padding: 30px 120px 12px; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
padding: 20px 0; |
||||
width: 100%; |
||||
} |
||||
} |
||||
|
||||
&__confirmation-content { |
||||
padding-bottom: 60px; |
||||
} |
||||
|
||||
&__confirmation-token-list-item { |
||||
display: flex; |
||||
flex-flow: row nowrap; |
||||
margin: 0 auto; |
||||
align-items: center; |
||||
} |
||||
|
||||
&__confirmation-token-list-item + &__confirmation-token-list-item { |
||||
margin-top: 30px; |
||||
} |
||||
|
||||
&__confirmation-token-icon { |
||||
margin-right: 18px; |
||||
} |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
top: 0; |
||||
width: 100%; |
||||
overflow: hidden; |
||||
flex: 1 0 auto; |
||||
|
||||
&__wrapper { |
||||
box-shadow: none !important; |
||||
flex: 1 1 auto; |
||||
width: 100%; |
||||
overflow-y: scroll; |
||||
height: 400px; |
||||
} |
||||
|
||||
&__footers { |
||||
border-bottom: 1px solid $gallery; |
||||
} |
||||
|
||||
&__token-icon { |
||||
width: 50px; |
||||
height: 50px; |
||||
} |
||||
|
||||
&__token-symbol { |
||||
font-size: 18px; |
||||
line-height: 24px; |
||||
} |
||||
|
||||
&__token-name { |
||||
font-size: 12px; |
||||
line-height: 16px; |
||||
} |
||||
|
||||
&__buttons { |
||||
padding: 1rem; |
||||
margin: 0; |
||||
border-top: 1px solid $gallery; |
||||
width: 100%; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,105 @@ |
||||
import React, { Component } from 'react' |
||||
import PropTypes from 'prop-types' |
||||
import TokenTracker from 'eth-token-tracker' |
||||
|
||||
const withTokenTracker = WrappedComponent => { |
||||
return class TokenTrackerWrappedComponent extends Component { |
||||
static propTypes = { |
||||
userAddress: PropTypes.string.isRequired, |
||||
token: PropTypes.object.isRequired, |
||||
} |
||||
|
||||
constructor (props) { |
||||
super(props) |
||||
|
||||
this.state = { |
||||
string: '', |
||||
symbol: '', |
||||
error: null, |
||||
} |
||||
|
||||
this.tracker = null |
||||
this.updateBalance = this.updateBalance.bind(this) |
||||
this.setError = this.setError.bind(this) |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this.createFreshTokenTracker() |
||||
} |
||||
|
||||
componentDidUpdate (prevProps) { |
||||
const { userAddress: newAddress, token: { address: newTokenAddress } } = this.props |
||||
const { userAddress: oldAddress, token: { address: oldTokenAddress } } = prevProps |
||||
|
||||
if ((oldAddress === newAddress) && (oldTokenAddress === newTokenAddress)) { |
||||
return |
||||
} |
||||
|
||||
if ((!oldAddress || !newAddress) && (!oldTokenAddress || !newTokenAddress)) { |
||||
return |
||||
} |
||||
|
||||
this.createFreshTokenTracker() |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.removeListeners() |
||||
} |
||||
|
||||
createFreshTokenTracker () { |
||||
this.removeListeners() |
||||
|
||||
if (!global.ethereumProvider) { |
||||
return |
||||
} |
||||
|
||||
const { userAddress, token } = this.props |
||||
|
||||
this.tracker = new TokenTracker({ |
||||
userAddress, |
||||
provider: global.ethereumProvider, |
||||
tokens: [token], |
||||
pollingInterval: 8000, |
||||
}) |
||||
|
||||
this.tracker.on('update', this.updateBalance) |
||||
this.tracker.on('error', this.setError) |
||||
|
||||
this.tracker.updateBalances() |
||||
.then(() => this.updateBalance(this.tracker.serialize())) |
||||
.catch(error => this.setState({ error: error.message })) |
||||
} |
||||
|
||||
setError (error) { |
||||
this.setState({ error }) |
||||
} |
||||
|
||||
updateBalance (tokens = []) { |
||||
const [{ string, symbol }] = tokens |
||||
this.setState({ string, symbol, error: null }) |
||||
} |
||||
|
||||
removeListeners () { |
||||
if (this.tracker) { |
||||
this.tracker.stop() |
||||
this.tracker.removeListener('update', this.updateBalance) |
||||
this.tracker.removeListener('error', this.setError) |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { string, symbol, error } = this.state |
||||
|
||||
return ( |
||||
<WrappedComponent |
||||
{ ...this.props } |
||||
string={string} |
||||
symbol={symbol} |
||||
error={error} |
||||
/> |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
module.exports = withTokenTracker |
Loading…
Reference in new issue