From 803eaaf968161f16aaf72d59b979dfbb7fb9b352 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Fri, 13 Oct 2017 16:19:22 -0400 Subject: [PATCH] [NewUI] SendV2-#8: Send container handles tokens; gas info dynamic from state (#2364) * Adds memo field to send-v2. * Vertical align transaction with flexbox. * Customize Gas UI * Remove internal state from InputNumber and fix use in gastooltip. * Move customize-gas-modal to its own folder and minor cleanup * Create send container, get account info from state, and make currency display more reusable * Adjusts send-v2 and container for send-token. Dynamically getting suggested gas prices. --- ui/app/app.js | 9 +- .../customize-gas-modal/gas-modal-card.js | 55 +++++ .../customize-gas-modal/gas-slider.js | 50 +++++ .../components/customize-gas-modal/index.js | 91 ++++++++ ui/app/components/input-number.js | 29 +-- ui/app/components/modals/modal.js | 26 +++ ui/app/components/send/account-list-item.js | 33 ++- ui/app/components/send/currency-display.js | 67 ++++-- ui/app/components/send/from-dropdown.js | 2 +- ui/app/components/send/gas-fee-display-v2.js | 47 ++++ ui/app/components/send/gas-tooltip.js | 4 +- ui/app/components/send/memo-textarea.js | 33 +++ ui/app/components/send/send-v2-container.js | 62 ++++++ ui/app/components/send/to-autocomplete.js | 4 +- ui/app/conversion-util.js | 14 +- .../itcss/components/account-dropdown.scss | 25 ++- .../itcss/components/currency-display.scss | 4 - ui/app/css/itcss/components/gas-slider.scss | 51 +++++ ui/app/css/itcss/components/index.scss | 3 + ui/app/css/itcss/components/send.scss | 206 +++++++++++++++++- ui/app/selectors.js | 35 ++- ui/app/send-v2.js | 201 ++++++++++++----- 22 files changed, 917 insertions(+), 134 deletions(-) create mode 100644 ui/app/components/customize-gas-modal/gas-modal-card.js create mode 100644 ui/app/components/customize-gas-modal/gas-slider.js create mode 100644 ui/app/components/customize-gas-modal/index.js create mode 100644 ui/app/components/send/gas-fee-display-v2.js create mode 100644 ui/app/components/send/memo-textarea.js create mode 100644 ui/app/components/send/send-v2-container.js create mode 100644 ui/app/css/itcss/components/gas-slider.scss diff --git a/ui/app/app.js b/ui/app/app.js index 92fc5e697..08d24d86c 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -10,7 +10,7 @@ const NewKeyChainScreen = require('./new-keychain') // accounts const MainContainer = require('./main-container') const SendTransactionScreen = require('./send') -const SendTransactionScreen2 = require('./send-v2.js') +const SendTransactionScreen2 = require('./components/send/send-v2-container') const SendTokenScreen = require('./components/send-token') const ConfirmTxScreen = require('./conf-tx') // notice @@ -356,7 +356,12 @@ App.prototype.renderPrimary = function () { case 'sendToken': log.debug('rendering send token screen') - return h(SendTokenScreen, {key: 'sendToken'}) + + const SendTokenComponentToRender = checkFeatureToggle('send-v2') + ? SendTransactionScreen2 + : SendTokenScreen + + return h(SendTokenComponentToRender, {key: 'sendToken'}) case 'newKeychain': log.debug('rendering new keychain screen') diff --git a/ui/app/components/customize-gas-modal/gas-modal-card.js b/ui/app/components/customize-gas-modal/gas-modal-card.js new file mode 100644 index 000000000..8e739ee40 --- /dev/null +++ b/ui/app/components/customize-gas-modal/gas-modal-card.js @@ -0,0 +1,55 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const InputNumber = require('../input-number.js') +const GasSlider = require('./gas-slider.js') + +module.exports = GasModalCard + +inherits(GasModalCard, Component) +function GasModalCard () { + Component.call(this) +} + +GasModalCard.prototype.render = function () { + const { + memo, + identities, + onChange, + unitLabel, + value, + min, + max, + step, + title, + copy + } = this.props + + return h('div.send-v2__gas-modal-card', [ + + h('div.send-v2__gas-modal-card__title', {}, title), + + h('div.send-v2__gas-modal-card__copy', {}, copy), + + h(InputNumber, { + unitLabel, + step, + max, + min, + placeholder: '0', + value, + onChange, + }), + + h(GasSlider, { + value, + step, + max, + min, + onChange, + }), + + ]) + +} + diff --git a/ui/app/components/customize-gas-modal/gas-slider.js b/ui/app/components/customize-gas-modal/gas-slider.js new file mode 100644 index 000000000..e76e96545 --- /dev/null +++ b/ui/app/components/customize-gas-modal/gas-slider.js @@ -0,0 +1,50 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = GasSlider + +inherits(GasSlider, Component) +function GasSlider () { + Component.call(this) +} + +GasSlider.prototype.render = function () { + const { + memo, + identities, + onChange, + unitLabel, + value, + id, + step, + max, + min, + } = this.props + + return h('div.gas-slider', [ + + h('input.gas-slider__input', { + type: 'range', + step, + max, + min, + value, + id: 'gasSlider', + onChange: event => onChange(event.target.value), + }, []), + + h('div.gas-slider__bar', [ + + h('div.gas-slider__low'), + + h('div.gas-slider__mid'), + + h('div.gas-slider__high'), + + ]), + + ]) + +} + diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js new file mode 100644 index 000000000..91e2626b4 --- /dev/null +++ b/ui/app/components/customize-gas-modal/index.js @@ -0,0 +1,91 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const GasModalCard = require('./gas-modal-card') + +function mapStateToProps (state) { + return {} +} + +function mapDispatchToProps (dispatch) { + return { + hideModal: () => dispatch(actions.hideModal()), + } +} + +inherits(CustomizeGasModal, Component) +function CustomizeGasModal () { + Component.call(this) + + this.state = { + gasPrice: '0.23', + gasLimit: '25000', + } +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) + +CustomizeGasModal.prototype.render = function () { + const { hideModal } = this.props + const { gasPrice, gasLimit } = this.state + + return h('div.send-v2__customize-gas', {}, [ + h('div', { + }, [ + h('div.send-v2__customize-gas__header', {}, [ + + h('div.send-v2__customize-gas__title', 'Customize Gas'), + + h('div.send-v2__customize-gas__close', { + onClick: hideModal, + }), + + ]), + + h('div.send-v2__customize-gas__body', {}, [ + + h(GasModalCard, { + value: gasPrice, + min: 0.0, + max: 5.0, + step: 0.01, + onChange: gasPrice => this.setState({ gasPrice }), + title: 'Gas Price', + copy: 'We calculate the suggested gas prices based on network success rates.', + }), + + h(GasModalCard, { + value: gasLimit, + min: 20000, + max: 100000, + step: 1, + onChange: gasLimit => this.setState({ gasLimit }), + title: 'Gas Limit', + copy: 'We calculate the suggested gas limit based on network success rates.', + }), + + ]), + + h('div.send-v2__customize-gas__footer', {}, [ + + h('div.send-v2__customize-gas__revert', { + onClick: () => console.log('Revert'), + }, ['Revert']), + + h('div.send-v2__customize-gas__buttons', [ + h('div.send-v2__customize-gas__cancel', { + onClick: this.props.hideModal, + }, ['CANCEL']), + + h('div.send-v2__customize-gas__save', { + onClick: () => console.log('Save'), + }, ['SAVE']), + ]) + + ]), + + ]), + ]) +} diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js index 2824d77aa..16347fd5e 100644 --- a/ui/app/components/input-number.js +++ b/ui/app/components/input-number.js @@ -1,6 +1,7 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits +const { addCurrencies } = require('../conversion-util') module.exports = InputNumber @@ -8,49 +9,37 @@ inherits(InputNumber, Component) function InputNumber () { Component.call(this) - this.state = { - value: 0, - } - this.setValue = this.setValue.bind(this) } -InputNumber.prototype.componentWillMount = function () { - const { initValue = 0 } = this.props - - this.setState({ value: initValue }) -} - InputNumber.prototype.setValue = function (newValue) { - const { fixed, min = -1, onChange } = this.props + const { fixed, min = -1, max = Infinity, onChange } = this.props - if (fixed) newValue = Number(newValue.toFixed(4)) + newValue = Number(fixed ? newValue.toFixed(4) : newValue) - if (newValue >= min) { - this.setState({ value: newValue }) + if (newValue >= min && newValue <= max) { onChange(newValue) } } InputNumber.prototype.render = function () { - const { unitLabel, step = 1, placeholder } = this.props - const { value } = this.state + const { unitLabel, step = 1, placeholder, value = 0 } = this.props return h('div.customize-gas-input-wrapper', {}, [ h('input.customize-gas-input', { placeholder, type: 'number', - value, - onChange: (e) => this.setValue(Number(e.target.value)), + value: value, + onChange: (e) => this.setValue(e.target.value), }), h('span.gas-tooltip-input-detail', {}, [unitLabel]), h('div.gas-tooltip-input-arrows', {}, [ h('i.fa.fa-angle-up', { - onClick: () => this.setValue(value + step), + onClick: () => this.setValue(addCurrencies(value, step)), }), h('i.fa.fa-angle-down', { style: { cursor: 'pointer' }, - onClick: () => this.setValue(value - step), + onClick: () => this.setValue(addCurrencies(value, step * -1)), }), ]), ]) diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 7247d840e..88deb2bb0 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -15,6 +15,7 @@ const ExportPrivateKeyModal = require('./export-private-key-modal') const NewAccountModal = require('./new-account-modal') const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') +const CustomizeGasModal = require('../customize-gas-modal') const accountModalStyle = { mobileModalStyle: { @@ -156,6 +157,31 @@ const MODALS = { }, }, + CUSTOMIZE_GAS: { + contents: [ + h(CustomizeGasModal, {}, []), + ], + mobileModalStyle: { + width: '355px', + height: '598px', + // top: isPopupOrNotification() === 'popup' ? '52vh' : '36.5vh', + top: '5%', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '720px', + height: '377px', + top: '80px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + DEFAULT: { contents: [], mobileModalStyle: {}, diff --git a/ui/app/components/send/account-list-item.js b/ui/app/components/send/account-list-item.js index b11527d95..64acde767 100644 --- a/ui/app/components/send/account-list-item.js +++ b/ui/app/components/send/account-list-item.js @@ -3,27 +3,34 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect const Identicon = require('../identicon') +const CurrencyDisplay = require('./currency-display') +const { conversionRateSelector } = require('../../selectors') inherits(AccountListItem, Component) function AccountListItem () { Component.call(this) } -module.exports = AccountListItem +function mapStateToProps(state) { + return { + conversionRate: conversionRateSelector(state) + } +} + +module.exports = connect(mapStateToProps)(AccountListItem) AccountListItem.prototype.render = function () { const { account, handleClick, icon = null, + conversionRate, } = this.props - const { identity, balancesToRender } = account - const { name, address } = identity - const { primary, secondary } = balancesToRender + const { name, address, balance } = account return h('div.account-list-item', { - onClick: () => handleClick(identity), + onClick: () => handleClick({ name, address, balance }), }, [ h('div.account-list-item__top-row', {}, [ @@ -35,7 +42,7 @@ AccountListItem.prototype.render = function () { diameter: 18, className: 'account-list-item__identicon', }, - ), + ), h('div.account-list-item__account-name', {}, name), @@ -43,9 +50,17 @@ AccountListItem.prototype.render = function () { ]), - h('div.account-list-item__account-primary-balance', {}, primary), - - h('div.account-list-item__account-secondary-balance', {}, secondary), + h(CurrencyDisplay, { + primaryCurrency: 'ETH', + convertedCurrency: 'USD', + value: balance, + conversionRate, + convertedPrefix: '$', + readOnly: true, + className: 'account-list-item__account-balances', + primaryBalanceClassName: 'account-list-item__account-primary-balance', + convertedBalanceClassName: 'account-list-item__account-secondary-balance', + }, name), ]) } \ No newline at end of file diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display.js index 332d722ec..ed9847fdb 100644 --- a/ui/app/components/send/currency-display.js +++ b/ui/app/components/send/currency-display.js @@ -11,8 +11,7 @@ function CurrencyDisplay () { Component.call(this) this.state = { - minWidth: null, - currentScrollWidth: null, + value: null, } } @@ -29,28 +28,50 @@ function resetCaretIfPastEnd (value, event) { } } +CurrencyDisplay.prototype.handleChangeInHexWei = function (value) { + const { handleChange } = this.props + + const valueInHexWei = conversionUtil(value, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + toDenomination: 'WEI', + }) + + handleChange(valueInHexWei) +} + CurrencyDisplay.prototype.render = function () { const { - className, + className = 'currency-display', + primaryBalanceClassName = 'currency-display__input', + convertedBalanceClassName = 'currency-display__converted-value', + conversionRate, primaryCurrency, convertedCurrency, - value = '', - placeholder = '0', - conversionRate, convertedPrefix = '', + placeholder = '0', readOnly = false, - handleChange, + value: initValue, } = this.props - const { minWidth } = this.state + const { value } = this.state - const convertedValue = conversionUtil(value, { - fromNumericBase: 'dec', - fromCurrency: primaryCurrency, - toCurrency: convertedCurrency, + const initValueToRender = conversionUtil(initValue, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + numberOfDecimals: 6, conversionRate, }) - return h('div.currency-display', { + const convertedValue = conversionUtil(value || initValueToRender, { + fromNumericBase: 'dec', + fromCurrency: primaryCurrency, + toCurrency: convertedCurrency, + numberOfDecimals: 2, + conversionRate, + }) + + return h('div', { className, }, [ @@ -58,35 +79,39 @@ CurrencyDisplay.prototype.render = function () { h('div.currency-display__input-wrapper', [ - h('input.currency-display__input', { - value: `${value} ${primaryCurrency}`, + h('input', { + className: primaryBalanceClassName, + value: `${value || initValueToRender} ${primaryCurrency}`, placeholder: `${0} ${primaryCurrency}`, readOnly, onChange: (event) => { let newValue = event.target.value.split(' ')[0] if (newValue === '') { - handleChange('0') + this.setState({ value: '0' }) } else if (newValue.match(/^0[1-9]$/)) { - handleChange(newValue.match(/[1-9]/)[0]) + this.setState({ value: newValue.match(/[1-9]/)[0] }) } else if (newValue && !isValidInput(newValue)) { event.preventDefault() } else { - handleChange(newValue) + this.setState({ value: newValue }) } }, - onKeyUp: event => resetCaretIfPastEnd(value, event), - onClick: event => resetCaretIfPastEnd(value, event), + onBlur: event => this.handleChangeInHexWei(event.target.value.split(' ')[0]), + onKeyUp: event => resetCaretIfPastEnd(value || initValueToRender, event), + onClick: event => resetCaretIfPastEnd(value || initValueToRender, event), }), ]), ]), - h('div.currency-display__converted-value', {}, `${convertedPrefix}${convertedValue} ${convertedCurrency}`), + h('div', { + className: convertedBalanceClassName, + }, `${convertedPrefix}${convertedValue} ${convertedCurrency}`), ]) diff --git a/ui/app/components/send/from-dropdown.js b/ui/app/components/send/from-dropdown.js index fb0a00cc2..e8e1d43f0 100644 --- a/ui/app/components/send/from-dropdown.js +++ b/ui/app/components/send/from-dropdown.js @@ -14,7 +14,7 @@ function FromDropdown () { FromDropdown.prototype.getListItemIcon = function (currentAccount, selectedAccount) { const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } }) - return currentAccount.identity.address === selectedAccount.identity.address + return currentAccount.address === selectedAccount.address ? listItemIcon : null } diff --git a/ui/app/components/send/gas-fee-display-v2.js b/ui/app/components/send/gas-fee-display-v2.js new file mode 100644 index 000000000..226ae93f8 --- /dev/null +++ b/ui/app/components/send/gas-fee-display-v2.js @@ -0,0 +1,47 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const CurrencyDisplay = require('./currency-display'); + +const { multiplyCurrencies } = require('../../conversion-util') + +module.exports = GasFeeDisplay + +inherits(GasFeeDisplay, Component) +function GasFeeDisplay () { + Component.call(this) +} + +GasFeeDisplay.prototype.render = function () { + const { + conversionRate, + gasLimit, + gasPrice, + onClick, + } = this.props + + const readyToRender = Boolean(gasLimit && gasPrice) + + return h('div', [ + + readyToRender + ? h(CurrencyDisplay, { + primaryCurrency: 'ETH', + convertedCurrency: 'USD', + value: multiplyCurrencies(gasLimit, gasPrice, { toNumericBase: 'hex' }), + conversionRate, + convertedPrefix: '$', + readOnly: true, + }) + : h('div.currency-display', 'Loading...') + , + + h('div.send-v2__sliders-icon-container', { + onClick, + }, [ + h('i.fa.fa-sliders.send-v2__sliders-icon'), + ]) + + ]) +} + diff --git a/ui/app/components/send/gas-tooltip.js b/ui/app/components/send/gas-tooltip.js index bef419e48..46aff3499 100644 --- a/ui/app/components/send/gas-tooltip.js +++ b/ui/app/components/send/gas-tooltip.js @@ -73,7 +73,7 @@ GasTooltip.prototype.render = function () { step: 1, min: 0, placeholder: '0', - initValue: gasPrice, + value: gasPrice, onChange: (newPrice) => this.updateGasPrice(newPrice), }), h('div.gas-tooltip-input-label', { @@ -89,7 +89,7 @@ GasTooltip.prototype.render = function () { step: 1, min: 0, placeholder: '0', - initValue: gasLimit, + value: gasLimit, onChange: (newLimit) => this.updateGasLimit(newLimit), }), ]), diff --git a/ui/app/components/send/memo-textarea.js b/ui/app/components/send/memo-textarea.js new file mode 100644 index 000000000..4005b9493 --- /dev/null +++ b/ui/app/components/send/memo-textarea.js @@ -0,0 +1,33 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits +const Identicon = require('../identicon') + +module.exports = MemoTextArea + +inherits(MemoTextArea, Component) +function MemoTextArea () { + Component.call(this) +} + +MemoTextArea.prototype.render = function () { + const { memo, identities, onChange } = this.props + + return h('div.send-v2__memo-text-area', [ + + h('textarea.send-v2__memo-text-area__input', { + placeholder: 'Optional', + value: memo, + onChange, + // onBlur: () => { + // this.setErrorsFor('memo') + // }, + onFocus: event => { + // this.clearErrorsFor('memo') + }, + }), + + ]) + +} + diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js new file mode 100644 index 000000000..0c8dd5335 --- /dev/null +++ b/ui/app/components/send/send-v2-container.js @@ -0,0 +1,62 @@ +const connect = require('react-redux').connect +const actions = require('../../actions') +const abi = require('ethereumjs-abi') +const SendEther = require('../../send-v2') + +const { multiplyCurrencies } = require('../../conversion-util') + +const { + accountsWithSendEtherInfoSelector, + getCurrentAccountWithSendEtherInfo, + conversionRateSelector, + getSelectedToken, + getSelectedTokenExchangeRate, + getSelectedAddress, +} = require('../../selectors') + +module.exports = connect(mapStateToProps, mapDispatchToProps)(SendEther) + +function mapStateToProps (state) { + const selectedAddress = getSelectedAddress(state); + const selectedToken = getSelectedToken(state); + const tokenExchangeRates = state.metamask.tokenExchangeRates + const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) + const conversionRate = conversionRateSelector(state) + + let data; + let primaryCurrency; + let tokenToUSDRate; + if (selectedToken) { + data = Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [selectedAddress, '0x0']), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + primaryCurrency = selectedToken.symbol + + tokenToUSDRate = multiplyCurrencies( + conversionRate, + selectedTokenExchangeRate, + { toNumericBase: 'dec' } + ) + } + + return { + selectedAccount: getCurrentAccountWithSendEtherInfo(state), + accounts: accountsWithSendEtherInfoSelector(state), + conversionRate, + selectedToken, + primaryCurrency, + data, + tokenToUSDRate, + } +} + +function mapDispatchToProps (dispatch) { + return { + showCustomizeGasModal: () => dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' })), + estimateGas: params => dispatch(actions.estimateGas(params)), + getGasPrice: () => dispatch(actions.getGasPrice()), + updateTokenExchangeRate: token => dispatch(actions.updateTokenExchangeRate(token)), + } +} diff --git a/ui/app/components/send/to-autocomplete.js b/ui/app/components/send/to-autocomplete.js index 3808bf496..1bf1e1907 100644 --- a/ui/app/components/send/to-autocomplete.js +++ b/ui/app/components/send/to-autocomplete.js @@ -11,7 +11,7 @@ function ToAutoComplete () { } ToAutoComplete.prototype.render = function () { - const { to, identities, onChange } = this.props + const { to, accounts, onChange } = this.props return h('div.send-v2__to-autocomplete', [ @@ -32,7 +32,7 @@ ToAutoComplete.prototype.render = function () { h('datalist#addresses', [ // Corresponds to the addresses owned. - ...Object.entries(identities).map(([key, { address, name }]) => { + ...Object.entries(accounts).map(([key, { address, name }]) => { return h('option', { value: address, label: name, diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js index 70c3c2622..3a702bcdd 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/conversion-util.js @@ -128,7 +128,8 @@ const conversionUtil = (value, { value: value || '0', }); -const addCurrencies = (a, b, { toNumericBase, numberOfDecimals }) => { +const addCurrencies = (a, b, options = {}) => { + const { toNumericBase, numberOfDecimals } = options const value = (new BigNumber(a)).add(b); return converter({ value, @@ -137,6 +138,16 @@ const addCurrencies = (a, b, { toNumericBase, numberOfDecimals }) => { }) } +const multiplyCurrencies = (a, b, options = {}) => { + const { toNumericBase, numberOfDecimals } = options + const value = (new BigNumber(a)).times(b); + return converter({ + value, + toNumericBase, + numberOfDecimals, + }) +} + const conversionGreaterThan = ( { value, fromNumericBase }, { value: compareToValue, fromNumericBase: compareToBase }, @@ -152,5 +163,6 @@ const conversionGreaterThan = ( module.exports = { conversionUtil, addCurrencies, + multiplyCurrencies, conversionGreaterThan, } \ No newline at end of file diff --git a/ui/app/css/itcss/components/account-dropdown.scss b/ui/app/css/itcss/components/account-dropdown.scss index 9966c7f3f..4fc7c705a 100644 --- a/ui/app/css/itcss/components/account-dropdown.scss +++ b/ui/app/css/itcss/components/account-dropdown.scss @@ -23,6 +23,16 @@ margin-left: 8px; position: relative; } + + &__account-balances { + height: auto; + border: none; + background-color: transparent; + color: #9b9b9b; + margin-left: 34px; + margin-top: 4px; + position: relative; + } &__account-name { font-size: 16px; @@ -34,13 +44,22 @@ right: 12px; top: 1px; } + + &__account-primary-balance, + &__account-secondary-balance { + font-family: Roboto; + line-height: 16px; + font-size: 12px; + font-weight: 300; + } + &__account-primary-balance { - margin-left: 34px; - margin-top: 4px; + color: $scorpion; + border: none; + outline: 0 !important; } &__account-secondary-balance { - margin-left: 34px; color: $dusty-gray; } } diff --git a/ui/app/css/itcss/components/currency-display.scss b/ui/app/css/itcss/components/currency-display.scss index b2776bb47..f2cc6e700 100644 --- a/ui/app/css/itcss/components/currency-display.scss +++ b/ui/app/css/itcss/components/currency-display.scss @@ -15,10 +15,6 @@ display: flex; } - &__input-wrapper { - margin-top: -1px; - } - &__input { color: $scorpion; font-family: Roboto; diff --git a/ui/app/css/itcss/components/gas-slider.scss b/ui/app/css/itcss/components/gas-slider.scss new file mode 100644 index 000000000..c27a560bd --- /dev/null +++ b/ui/app/css/itcss/components/gas-slider.scss @@ -0,0 +1,51 @@ +.gas-slider { + position: relative; + width: 313px; + + &__input { + width: 317px; + margin-left: -2px; + z-index: 2; + } + + input[type=range] { + -webkit-appearance: none !important; + } + + input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none !important; + height: 26px; + width: 26px; + border: 2px solid #B8B8B8; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.08); + border-radius: 50%; + position: relative; + z-index: 10; + } + + &__bar { + height: 6px; + width: 313px; + background: $alto; + display: flex; + justify-content: space-between; + position: absolute; + top: 11px; + z-index: 0; + } + + &__low, &__high { + height: 6px; + width: 49px; + z-index: 1; + } + + &__low { + background-color: $crimson; + } + + &__high { + background-color: $caribbean-green; + } +} \ No newline at end of file diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index dee0959b7..fda002785 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -35,3 +35,6 @@ @import './account-menu.scss'; @import './menu.scss'; + +@import './gas-slider.scss'; + diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index 80aacf1ab..ddabdee2e 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -264,7 +264,7 @@ .gas-tooltip-input-arrows { position: absolute; top: 0; - left: 178px; + right: 4px; width: 17px; height: 28px; border: 1px solid #dadada; @@ -420,7 +420,16 @@ } } - &__send-eth-icon { + &__send-header-icon-container { + z-index: 25; + + @media screen and (max-width: $break-small) { + position: relative; + top: 0; + } + } + + &__send-header-icon { border-radius: 50%; width: 48px; height: 48px; @@ -428,11 +437,6 @@ z-index: 25; padding: 4px; background-color: $white; - - @media screen and (max-width: $break-small) { - position: relative; - top: 0; - } } &__send-arrow-icon { @@ -472,7 +476,7 @@ position: absolute; transform: rotate(45deg); left: 178px; - top: 71px; + top: 65px; } &__title { @@ -512,7 +516,9 @@ font-family: Roboto; font-size: 16px; line-height: 22px; - margin-top: 16px; + display: flex; + flex-flow: column; + justify-content: center; } &__from-dropdown { @@ -550,7 +556,7 @@ } } - &__to-autocomplete { + &__to-autocomplete, &__memo-text-area { &__input { height: 54px; width: 240px; @@ -566,6 +572,32 @@ } } + &__sliders-icon-container { + display: flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + border: 1px solid $curious-blue; + border-radius: 4px; + background-color: $white; + padding: 5px; + position: absolute; + right: 15px; + top: 14px; + cursor: pointer; + } + + &__sliders-icon { + color: $curious-blue; + } + + &__memo-text-area { + &__input { + padding: 6px 10px; + } + } + &__footer { height: 92px; width: 100%; @@ -573,8 +605,7 @@ justify-content: space-evenly; align-items: center; border-top: 1px solid $alto; - position: absolute; - bottom: 0; + margin-top: 29px; } &__next-btn, @@ -607,4 +638,155 @@ color: $dusty-gray; border-color: $dusty-gray; } + + &__customize-gas { + border: 1px solid #D8D8D8; + border-radius: 4px; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.14); + font-family: Roboto; + display: flex; + flex-flow: column; + + @media screen and (max-width: $break-small) { + width: 355px; + height: 598px; + } + + &__header { + height: 52px; + border-bottom: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + } + + &__title { + margin-left: 19.25px; + } + + &__close::after { + content: '\00D7'; + font-size: 1.8em; + color: $dusty-gray; + font-family: sans-serif; + cursor: pointer; + margin-right: 19.25px; + } + + &__body { + height: 248px; + display: flex; + + @media screen and (max-width: $break-small) { + width: 355px; + height: 470px; + flex-flow: column; + } + } + + &__footer { + height: 75px; + border-top: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + } + + &__buttons { + display: flex; + justify-content: space-between; + width: 181.75px; + margin-right: 21.25px; + } + + &__revert, &__cancel, &__save { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + &__revert { + color: $silver-chalice; + font-size: 16px; + margin-left: 21.25px; + } + + &__cancel, &__save { + height: 34.64px; + width: 85.74px; + border: 1px solid $dusty-gray; + border-radius: 2px; + font-family: 'DIN OT'; + font-size: 12px; + color: $dusty-gray; + } + } + + &__gas-modal-card { + width: 360px; + display: flex; + flex-flow: column; + align-items: flex-start; + padding-left: 20px; + + &__title { + height: 26px; + width: 84px; + color: $tundora; + font-family: Roboto; + font-size: 20px; + font-weight: 300; + line-height: 26px; + margin-top: 17px; + } + + &__copy { + height: 38px; + width: 314px; + color: $tundora; + font-family: Roboto; + font-size: 14px; + line-height: 19px; + margin-top: 17px; + } + + .customize-gas-input-wrapper { + margin-top: 17px; + } + + .customize-gas-input { + height: 54px; + width: 315px; + border: 1px solid $geyser; + background-color: $white; + padding-left: 15px; + } + + .gas-tooltip-input-arrows { + width: 32px; + height: 54px; + border-left: 1px solid #dadada; + font-size: 18px; + color: $tundora; + right: 0px; + padding: 1px 4px; + display: flex; + justify-content: space-around; + align-items: center; + } + + 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; + } + } } diff --git a/ui/app/selectors.js b/ui/app/selectors.js index fdbc5fcde..951161510 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -5,14 +5,16 @@ const selectors = { getSelectedIdentity, getSelectedAccount, getSelectedToken, + getSelectedTokenExchangeRate, conversionRateSelector, transactionsSelector, + accountsWithSendEtherInfoSelector, + getCurrentAccountWithSendEtherInfo, } module.exports = selectors function getSelectedAddress (state) { - // TODO: accounts is not defined. Is it needed? const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] return selectedAddress @@ -40,10 +42,41 @@ function getSelectedToken (state) { return selectedToken || null } +function getSelectedTokenExchangeRate (state) { + const tokenExchangeRates = state.metamask.tokenExchangeRates + const selectedToken = getSelectedToken(state) || {} + const { symbol = '' } = selectedToken + + const pair = `${symbol.toLowerCase()}_eth` + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + function conversionRateSelector (state) { return state.metamask.conversionRate } +function accountsWithSendEtherInfoSelector (state) { + const { + accounts, + identities, + } = state.metamask + + const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { + return Object.assign({}, account, identities[key]) + }) + + return accountsWithSendEtherInfo +} + +function getCurrentAccountWithSendEtherInfo (state) { + const currentAddress = getSelectedAddress(state) + const accounts = accountsWithSendEtherInfoSelector(state) + + return accounts.find(({ address }) => address === currentAddress) +} + function transactionsSelector (state) { const { network, selectedTokenAddress } = state.metamask const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js index 47f8b18bd..af7586859 100644 --- a/ui/app/send-v2.js +++ b/ui/app/send-v2.js @@ -2,61 +2,135 @@ const { inherits } = require('util') const PersistentForm = require('../lib/persistent-form') const h = require('react-hyperscript') const connect = require('react-redux').connect + +const Identicon = require('./components/identicon') const FromDropdown = require('./components/send/from-dropdown') const ToAutoComplete = require('./components/send/to-autocomplete') const CurrencyDisplay = require('./components/send/currency-display') +const MemoTextArea = require('./components/send/memo-textarea') +const GasFeeDisplay = require('./components/send/gas-fee-display-v2') -module.exports = connect(mapStateToProps)(SendTransactionScreen) +const { showModal } = require('./actions') -function mapStateToProps (state) { - const mockAccounts = Array.from(new Array(5)) - .map((v, i) => ({ - identity: { - name: `Test Account Name ${i}`, - address: `0x02f567704cc6569127e18e3d00d2c85bcbfa6f0${i}`, - }, - balancesToRender: { - primary: `100${i}.000001 ETH`, - secondary: `$30${i},000.00 USD`, - } - })) - const conversionRate = 301.0005 - - return { - accounts: mockAccounts, - conversionRate - } -} +module.exports = SendTransactionScreen inherits(SendTransactionScreen, PersistentForm) function SendTransactionScreen () { PersistentForm.call(this) this.state = { - newTx: { - from: '', - to: '', - gasPrice: null, - gas: '0.001', - amount: '10', - txData: null, - memo: '', - }, + from: '', + to: '', + gasPrice: null, + gasLimit: null, + amount: '0x0', + txData: null, + memo: '', dropdownOpen: false, } } +SendTransactionScreen.prototype.componentWillMount = function () { + const { + updateTokenExchangeRate, + selectedToken = {}, + getGasPrice, + estimateGas, + selectedAddress, + data, + } = this.props + const { symbol } = selectedToken || {} + + const estimateGasParams = { + from: selectedAddress, + gas: '746a528800', + } + + if (symbol) { + updateTokenExchangeRate(symbol) + Object.assign(estimateGasParams, { value: '0x0' }) + } + + if (data) { + Object.assign(estimateGasParams, { data }) + } + + Promise.all([ + getGasPrice(), + estimateGas({ + from: selectedAddress, + gas: '746a528800', + }), + ]) + .then(([blockGasPrice, estimatedGas]) => { + this.setState({ + gasPrice: blockGasPrice, + gasLimit: estimatedGas, + }) + }) +} + +SendTransactionScreen.prototype.renderHeaderIcon = function () { + const { selectedToken } = this.props + + return h('div.send-v2__send-header-icon-container', [ + selectedToken + ? h(Identicon, { + diameter: 40, + address: selectedToken.address, + }) + : h('img.send-v2__send-header-icon', { src: '../images/eth_logo.svg' }) + ]) +} + +SendTransactionScreen.prototype.renderTitle = function () { + const { selectedToken } = this.props + + return h('div.send-v2__title', [selectedToken ? 'Send Tokens' : 'Send Funds']) +} + +SendTransactionScreen.prototype.renderCopy = function () { + const { selectedToken } = this.props + + const tokenText = selectedToken ? 'tokens' : 'ETH' + + return h('div', [ + + h('div.send-v2__copy', `Only send ${tokenText} to an Ethereum address.`), + + h('div.send-v2__copy', 'Sending to a different crytpocurrency that is not Ethereum may result in permanent loss.'), + + ]) +} + SendTransactionScreen.prototype.render = function () { - const { accounts, conversionRate } = this.props - const { dropdownOpen, newTx } = this.state - const { to, amount, gas } = newTx + const { + accounts, + conversionRate, + tokenToUSDRate, + selectedToken, + showCustomizeGasModal, + selectedAccount, + primaryCurrency = 'ETH', + } = this.props + + const { + dropdownOpen, + to, + amount, + gasLimit, + gasPrice, + memo, + } = this.state + + const amountConversionRate = selectedToken ? tokenToUSDRate : conversionRate return ( h('div.send-v2__container', [ h('div.send-v2__header', {}, [ - h('img.send-v2__send-eth-icon', { src: '../images/eth_logo.svg' }), + this.renderHeaderIcon(), h('div.send-v2__arrow-background', [ h('i.fa.fa-lg.fa-arrow-circle-right.send-v2__send-arrow-icon'), @@ -66,11 +140,9 @@ SendTransactionScreen.prototype.render = function () { ]), - h('div.send-v2__title', 'Send Funds'), + this.renderTitle(), - h('div.send-v2__copy', 'Only send ETH to an Ethereum address.'), - - h('div.send-v2__copy', 'Sending to a different crytpocurrency that is not Ethereum may result in permanent loss.'), + this.renderCopy(), h('div.send-v2__form', {}, [ @@ -81,10 +153,11 @@ SendTransactionScreen.prototype.render = function () { h(FromDropdown, { dropdownOpen, accounts, - selectedAccount: accounts[0], + selectedAccount, setFromField: () => console.log('Set From Field'), openDropdown: () => this.setState({ dropdownOpen: true }), closeDropdown: () => this.setState({ dropdownOpen: false }), + conversionRate, }), ]), @@ -95,13 +168,11 @@ SendTransactionScreen.prototype.render = function () { h(ToAutoComplete, { to, - identities: accounts.map(({ identity }) => identity), + accounts, onChange: (event) => { this.setState({ - newTx: { - ...this.state.newTx, - to: event.target.value, - }, + ...this.state, + to: event.target.value, }) }, }), @@ -113,17 +184,15 @@ SendTransactionScreen.prototype.render = function () { h('div.send-v2__form-label', 'Amount:'), h(CurrencyDisplay, { - primaryCurrency: 'ETH', + primaryCurrency, convertedCurrency: 'USD', value: amount, - conversionRate, + conversionRate: amountConversionRate, convertedPrefix: '$', handleChange: (value) => { this.setState({ - newTx: { - ...this.state.newTx, - amount: value, - }, + ...this.state, + amount: value, }) } }), @@ -134,14 +203,34 @@ SendTransactionScreen.prototype.render = function () { h('div.send-v2__form-label', 'Gas fee:'), - h(CurrencyDisplay, { - primaryCurrency: 'ETH', - convertedCurrency: 'USD', - value: gas, + h(GasFeeDisplay, { + gasLimit, + gasPrice, conversionRate, - convertedPrefix: '$', - readOnly: true, - }), + onClick: showCustomizeGasModal, + }), + + h('div.send-v2__sliders-icon-container', { + onClick: showCustomizeGasModal, + }, [ + h('i.fa.fa-sliders.send-v2__sliders-icon'), + ]) + + ]), + + h('div.send-v2__form-row', [ + + h('div.send-v2__form-label', 'Transaction Memo:'), + + h(MemoTextArea, { + memo, + onChange: (event) => { + this.setState({ + ...this.state, + memo: event.target.value, + }) + }, + }), ]),