From 3660ba28d73d70d08bf14c33ef680e5ef3ec7f3b Mon Sep 17 00:00:00 2001 From: Fabio Berger Date: Tue, 21 Nov 2017 14:03:08 -0600 Subject: Add website to mono repo, update packages to align with existing sub-packages, use new subscribeAsync 0x.js method --- .../components/dialogs/blockchain_err_dialog.tsx | 158 +++++ .../dialogs/eth_weth_conversion_dialog.tsx | 139 ++++ .../ts/components/dialogs/ledger_config_dialog.tsx | 288 +++++++++ .../dialogs/portal_disclaimer_dialog.tsx | 44 ++ .../website/ts/components/dialogs/send_dialog.tsx | 126 ++++ .../dialogs/track_token_confirmation_dialog.tsx | 99 +++ .../dialogs/u2f_not_supported_dialog.tsx | 53 ++ .../ts/components/eth_weth_conversion_button.tsx | 101 +++ packages/website/ts/components/fill_order.tsx | 714 +++++++++++++++++++++ packages/website/ts/components/fill_order_json.tsx | 69 ++ .../website/ts/components/fill_warning_dialog.tsx | 47 ++ .../flash_messages/token_send_completed.tsx | 38 ++ .../flash_messages/transaction_submitted.tsx | 29 + packages/website/ts/components/footer.tsx | 255 ++++++++ .../ts/components/generate_order/asset_picker.tsx | 291 +++++++++ .../generate_order/generate_order_form.tsx | 348 ++++++++++ .../components/generate_order/new_token_form.tsx | 237 +++++++ .../website/ts/components/inputs/address_input.tsx | 74 +++ .../ts/components/inputs/allowance_toggle.tsx | 94 +++ .../ts/components/inputs/balance_bounded_input.tsx | 160 +++++ .../ts/components/inputs/eth_amount_input.tsx | 51 ++ .../ts/components/inputs/expiration_input.tsx | 108 ++++ .../website/ts/components/inputs/hash_input.tsx | 65 ++ .../components/inputs/identicon_address_input.tsx | 56 ++ .../ts/components/inputs/token_amount_input.tsx | 69 ++ .../website/ts/components/inputs/token_input.tsx | 107 +++ packages/website/ts/components/order_json.tsx | 164 +++++ packages/website/ts/components/portal.tsx | 344 ++++++++++ packages/website/ts/components/portal_menu.tsx | 68 ++ packages/website/ts/components/send_button.tsx | 89 +++ packages/website/ts/components/token_balances.tsx | 697 ++++++++++++++++++++ packages/website/ts/components/top_bar.tsx | 370 +++++++++++ .../website/ts/components/top_bar_menu_item.tsx | 53 ++ .../ts/components/track_token_confirmation.tsx | 65 ++ .../ts/components/trade_history/trade_history.tsx | 115 ++++ .../trade_history/trade_history_item.tsx | 178 +++++ packages/website/ts/components/ui/alert.tsx | 27 + packages/website/ts/components/ui/badge.tsx | 58 ++ packages/website/ts/components/ui/copy_icon.tsx | 81 +++ .../ts/components/ui/drop_down_menu_item.tsx | 117 ++++ .../website/ts/components/ui/ethereum_address.tsx | 35 + .../website/ts/components/ui/etherscan_icon.tsx | 50 ++ .../website/ts/components/ui/fake_text_field.tsx | 35 + .../website/ts/components/ui/flash_message.tsx | 40 ++ packages/website/ts/components/ui/help_tooltip.tsx | 22 + packages/website/ts/components/ui/identicon.tsx | 36 ++ packages/website/ts/components/ui/input_label.tsx | 27 + .../website/ts/components/ui/labeled_switcher.tsx | 76 +++ .../ts/components/ui/lifecycle_raised_button.tsx | 105 +++ packages/website/ts/components/ui/loading.tsx | 36 ++ packages/website/ts/components/ui/menu_item.tsx | 54 ++ packages/website/ts/components/ui/party.tsx | 150 +++++ .../website/ts/components/ui/required_label.tsx | 15 + .../website/ts/components/ui/simple_loading.tsx | 23 + packages/website/ts/components/ui/swap_icon.tsx | 46 ++ packages/website/ts/components/ui/token_icon.tsx | 29 + packages/website/ts/components/visual_order.tsx | 77 +++ 57 files changed, 7102 insertions(+) create mode 100644 packages/website/ts/components/dialogs/blockchain_err_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/eth_weth_conversion_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/ledger_config_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/portal_disclaimer_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/send_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/track_token_confirmation_dialog.tsx create mode 100644 packages/website/ts/components/dialogs/u2f_not_supported_dialog.tsx create mode 100644 packages/website/ts/components/eth_weth_conversion_button.tsx create mode 100644 packages/website/ts/components/fill_order.tsx create mode 100644 packages/website/ts/components/fill_order_json.tsx create mode 100644 packages/website/ts/components/fill_warning_dialog.tsx create mode 100644 packages/website/ts/components/flash_messages/token_send_completed.tsx create mode 100644 packages/website/ts/components/flash_messages/transaction_submitted.tsx create mode 100644 packages/website/ts/components/footer.tsx create mode 100644 packages/website/ts/components/generate_order/asset_picker.tsx create mode 100644 packages/website/ts/components/generate_order/generate_order_form.tsx create mode 100644 packages/website/ts/components/generate_order/new_token_form.tsx create mode 100644 packages/website/ts/components/inputs/address_input.tsx create mode 100644 packages/website/ts/components/inputs/allowance_toggle.tsx create mode 100644 packages/website/ts/components/inputs/balance_bounded_input.tsx create mode 100644 packages/website/ts/components/inputs/eth_amount_input.tsx create mode 100644 packages/website/ts/components/inputs/expiration_input.tsx create mode 100644 packages/website/ts/components/inputs/hash_input.tsx create mode 100644 packages/website/ts/components/inputs/identicon_address_input.tsx create mode 100644 packages/website/ts/components/inputs/token_amount_input.tsx create mode 100644 packages/website/ts/components/inputs/token_input.tsx create mode 100644 packages/website/ts/components/order_json.tsx create mode 100644 packages/website/ts/components/portal.tsx create mode 100644 packages/website/ts/components/portal_menu.tsx create mode 100644 packages/website/ts/components/send_button.tsx create mode 100644 packages/website/ts/components/token_balances.tsx create mode 100644 packages/website/ts/components/top_bar.tsx create mode 100644 packages/website/ts/components/top_bar_menu_item.tsx create mode 100644 packages/website/ts/components/track_token_confirmation.tsx create mode 100644 packages/website/ts/components/trade_history/trade_history.tsx create mode 100644 packages/website/ts/components/trade_history/trade_history_item.tsx create mode 100644 packages/website/ts/components/ui/alert.tsx create mode 100644 packages/website/ts/components/ui/badge.tsx create mode 100644 packages/website/ts/components/ui/copy_icon.tsx create mode 100644 packages/website/ts/components/ui/drop_down_menu_item.tsx create mode 100644 packages/website/ts/components/ui/ethereum_address.tsx create mode 100644 packages/website/ts/components/ui/etherscan_icon.tsx create mode 100644 packages/website/ts/components/ui/fake_text_field.tsx create mode 100644 packages/website/ts/components/ui/flash_message.tsx create mode 100644 packages/website/ts/components/ui/help_tooltip.tsx create mode 100644 packages/website/ts/components/ui/identicon.tsx create mode 100644 packages/website/ts/components/ui/input_label.tsx create mode 100644 packages/website/ts/components/ui/labeled_switcher.tsx create mode 100644 packages/website/ts/components/ui/lifecycle_raised_button.tsx create mode 100644 packages/website/ts/components/ui/loading.tsx create mode 100644 packages/website/ts/components/ui/menu_item.tsx create mode 100644 packages/website/ts/components/ui/party.tsx create mode 100644 packages/website/ts/components/ui/required_label.tsx create mode 100644 packages/website/ts/components/ui/simple_loading.tsx create mode 100644 packages/website/ts/components/ui/swap_icon.tsx create mode 100644 packages/website/ts/components/ui/token_icon.tsx create mode 100644 packages/website/ts/components/visual_order.tsx (limited to 'packages/website/ts/components') diff --git a/packages/website/ts/components/dialogs/blockchain_err_dialog.tsx b/packages/website/ts/components/dialogs/blockchain_err_dialog.tsx new file mode 100644 index 000000000..2e12fc889 --- /dev/null +++ b/packages/website/ts/components/dialogs/blockchain_err_dialog.tsx @@ -0,0 +1,158 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import Dialog from 'material-ui/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import {colors} from 'material-ui/styles'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import {Blockchain} from 'ts/blockchain'; +import {BlockchainErrs} from 'ts/types'; + +interface BlockchainErrDialogProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + isOpen: boolean; + userAddress: string; + toggleDialogFn: (isOpen: boolean) => void; + networkId: number; +} + +export class BlockchainErrDialog extends React.Component { + public render() { + const dialogActions = [ + , + ]; + + const hasWalletAddress = this.props.userAddress !== ''; + return ( + +
+ {this.renderExplanation(hasWalletAddress)} +
+
+ ); + } + private getTitle(hasWalletAddress: boolean) { + if (this.props.blockchainErr === BlockchainErrs.A_CONTRACT_NOT_DEPLOYED_ON_NETWORK) { + return '0x smart contracts not found'; + } else if (!hasWalletAddress) { + return 'Enable wallet communication'; + } else if (this.props.blockchainErr === BlockchainErrs.DISCONNECTED_FROM_ETHEREUM_NODE) { + return 'Disconnected from Ethereum network'; + } else { + return 'Unexpected error'; + } + } + private renderExplanation(hasWalletAddress: boolean) { + if (this.props.blockchainErr === BlockchainErrs.A_CONTRACT_NOT_DEPLOYED_ON_NETWORK) { + return this.renderContractsNotDeployedExplanation(); + } else if (!hasWalletAddress) { + return this.renderNoWalletFoundExplanation(); + } else if (this.props.blockchainErr === BlockchainErrs.DISCONNECTED_FROM_ETHEREUM_NODE) { + return this.renderDisconnectedFromNode(); + } else { + return this.renderUnexpectedErrorExplanation(); + } + } + private renderDisconnectedFromNode() { + return ( +
+ You were disconnected from the backing Ethereum node. + {' '}If using + Metamask + or Mist try refreshing + {' '}the page. If using a locally hosted Ethereum node, make sure it's still running. +
+ ); + } + private renderUnexpectedErrorExplanation() { + return ( +
+ We encountered an unexpected error. Please try refreshing the page. +
+ ); + } + private renderNoWalletFoundExplanation() { + return ( +
+
+ We were unable to access an Ethereum wallet you control. In order to interact + {' '}with the 0x portal dApp, + we need a way to interact with one of your Ethereum wallets. + {' '}There are two easy ways you can enable us to do that: +
+

1. Metamask chrome extension

+
+ You can install the{' '} + + Metamask + Chrome extension Ethereum wallet. Once installed and set up, refresh this page. +
+ Note: + {' '}If you already have Metamask installed, make sure it is unlocked. +
+
+

Parity Signer

+
+ The Parity Signer + Chrome extension{' '}lets you connect to a locally running Parity node. + Make sure you have started your local Parity node with{' '} + {configs.isMainnetEnabled && '`parity ui` or'} `parity --chain kovan ui`{' '} + in order to connect to {configs.isMainnetEnabled ? 'mainnet or Kovan respectively.' : 'Kovan.'} +
+
+ Note: + {' '}If you have done one of the above steps and are still seeing this message, + {' '}we might still be unable to retrieve an Ethereum address by calling `web3.eth.accounts`. + {' '}Make sure you have created at least one Ethereum address. +
+
+ ); + } + private renderContractsNotDeployedExplanation() { + return ( +
+
+ The 0x smart contracts are not deployed on the Ethereum network you are + {' '}currently connected to (network Id: {this.props.networkId}). + {' '}In order to use the 0x portal dApp, + {' '}please connect to the + {' '}{constants.TESTNET_NAME} testnet (network Id: {constants.TESTNET_NETWORK_ID}) + {configs.isMainnetEnabled ? + ` or ${constants.MAINNET_NAME} (network Id: ${constants.MAINNET_NETWORK_ID}).` : + `.` + } +
+

Metamask

+
+ If you are using{' '} + + Metamask + , you can switch networks in the top left corner of the extension popover. +
+

Parity Signer

+
+ If using the Parity Signer + Chrome extension, make sure to start your local Parity node with{' '} + {configs.isMainnetEnabled ? + '`parity ui` or `parity --chain Kovan ui` in order to connect to mainnet \ + or Kovan respectively.' : + '`parity --chain kovan ui` in order to connect to Kovan.' + } +
+
+ ); + } +} diff --git a/packages/website/ts/components/dialogs/eth_weth_conversion_dialog.tsx b/packages/website/ts/components/dialogs/eth_weth_conversion_dialog.tsx new file mode 100644 index 000000000..1db85e375 --- /dev/null +++ b/packages/website/ts/components/dialogs/eth_weth_conversion_dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import Dialog from 'material-ui/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import RadioButtonGroup from 'material-ui/RadioButton/RadioButtonGroup'; +import RadioButton from 'material-ui/RadioButton'; +import {Side, Token, TokenState} from 'ts/types'; +import {TokenAmountInput} from 'ts/components/inputs/token_amount_input'; +import {EthAmountInput} from 'ts/components/inputs/eth_amount_input'; +import BigNumber from 'bignumber.js'; + +interface EthWethConversionDialogProps { + onComplete: (direction: Side, value: BigNumber) => void; + onCancelled: () => void; + isOpen: boolean; + token: Token; + tokenState: TokenState; + etherBalance: BigNumber; +} + +interface EthWethConversionDialogState { + value?: BigNumber; + direction: Side; + shouldShowIncompleteErrs: boolean; + hasErrors: boolean; +} + +export class EthWethConversionDialog extends + React.Component { + constructor() { + super(); + this.state = { + direction: Side.deposit, + shouldShowIncompleteErrs: false, + hasErrors: true, + }; + } + public render() { + const convertDialogActions = [ + , + , + ]; + return ( + + {this.renderConversionDialogBody()} + + ); + } + private renderConversionDialogBody() { + return ( +
+ + + + + {this.state.direction === Side.receive ? + : + + } +
+ ); + } + private onConversionDirectionChange(e: any, direction: Side) { + this.setState({ + value: undefined, + shouldShowIncompleteErrs: false, + direction, + hasErrors: true, + }); + } + private onValueChange(isValid: boolean, amount?: BigNumber) { + this.setState({ + value: amount, + hasErrors: !isValid, + }); + } + private onConvertClick() { + if (this.state.hasErrors) { + this.setState({ + shouldShowIncompleteErrs: true, + }); + } else { + const value = this.state.value; + this.setState({ + value: undefined, + }); + this.props.onComplete(this.state.direction, value); + } + } + private onCancel() { + this.setState({ + value: undefined, + }); + this.props.onCancelled(); + } +} diff --git a/packages/website/ts/components/dialogs/ledger_config_dialog.tsx b/packages/website/ts/components/dialogs/ledger_config_dialog.tsx new file mode 100644 index 000000000..f89935500 --- /dev/null +++ b/packages/website/ts/components/dialogs/ledger_config_dialog.tsx @@ -0,0 +1,288 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import {colors} from 'material-ui/styles'; +import Dialog from 'material-ui/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import TextField from 'material-ui/TextField'; +import { + Table, + TableBody, + TableHeader, + TableRow, + TableHeaderColumn, + TableRowColumn, +} from 'material-ui/Table'; +import ReactTooltip = require('react-tooltip'); +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; + +const VALID_ETHEREUM_DERIVATION_PATH_PREFIX = `44'/60'`; + +enum LedgerSteps { + CONNECT, + SELECT_ADDRESS, +} + +interface LedgerConfigDialogProps { + isOpen: boolean; + toggleDialogFn: (isOpen: boolean) => void; + dispatcher: Dispatcher; + blockchain: Blockchain; + networkId: number; +} + +interface LedgerConfigDialogState { + didConnectFail: boolean; + stepIndex: LedgerSteps; + userAddresses: string[]; + addressBalances: BigNumber[]; + derivationPath: string; + derivationErrMsg: string; +} + +export class LedgerConfigDialog extends React.Component { + constructor(props: LedgerConfigDialogProps) { + super(props); + this.state = { + didConnectFail: false, + stepIndex: LedgerSteps.CONNECT, + userAddresses: [], + addressBalances: [], + derivationPath: constants.DEFAULT_DERIVATION_PATH, + derivationErrMsg: '', + }; + } + public render() { + const dialogActions = [ + , + ]; + const dialogTitle = this.state.stepIndex === LedgerSteps.CONNECT ? + 'Connect to your Ledger' : + 'Select desired address'; + return ( + +
+ {this.state.stepIndex === LedgerSteps.CONNECT && + this.renderConnectStep() + } + {this.state.stepIndex === LedgerSteps.SELECT_ADDRESS && + this.renderSelectAddressStep() + } +
+
+ ); + } + private renderConnectStep() { + return ( +
+
+ Follow these instructions before proceeding: +
+
    +
  1. + Connect your Ledger Nano S & Open the Ethereum application +
  2. +
  3. + Verify that Browser Support is enabled in Settings +
  4. +
  5. + If no Browser Support is found in settings, verify that you have{' '} + Firmware >1.2 +
  6. +
+
+ + {this.state.didConnectFail && +
+ Failed to connect. Follow the instructions and try again. +
+ } +
+
+ ); + } + private renderSelectAddressStep() { + return ( +
+
+ + + + Address + Balance + + + + {this.renderAddressTableRows()} + +
+
+
+
+ +
+
+ +
+
+
+ ); + } + private renderAddressTableRows() { + const rows = _.map(this.state.userAddresses, (userAddress: string, i: number) => { + const balance = this.state.addressBalances[i]; + const addressTooltipId = `address-${userAddress}`; + const balanceTooltipId = `balance-${userAddress}`; + const networkName = constants.networkNameById[this.props.networkId]; + // We specifically prefix kovan ETH. + // TODO: We should probably add prefixes for all networks + const isKovanNetwork = networkName === 'Kovan'; + const balanceString = `${balance.toString()} ${isKovanNetwork ? 'Kovan ' : ''}ETH`; + return ( + + +
+ {userAddress} +
+ {userAddress} +
+ +
+ {balanceString} +
+ {balanceString} +
+
+ ); + }); + return rows; + } + private onClose() { + this.setState({ + didConnectFail: false, + }); + const isOpen = false; + this.props.toggleDialogFn(isOpen); + } + private onAddressSelected(selectedRowIndexes: number[]) { + const selectedRowIndex = selectedRowIndexes[0]; + this.props.blockchain.updateLedgerDerivationIndex(selectedRowIndex); + const selectedAddress = this.state.userAddresses[selectedRowIndex]; + const selectAddressBalance = this.state.addressBalances[selectedRowIndex]; + this.props.dispatcher.updateUserAddress(selectedAddress); + this.props.blockchain.updateWeb3WrapperPrevUserAddress(selectedAddress); + this.props.dispatcher.updateUserEtherBalance(selectAddressBalance); + this.setState({ + stepIndex: LedgerSteps.CONNECT, + }); + const isOpen = false; + this.props.toggleDialogFn(isOpen); + } + private async onFetchAddressesForDerivationPathAsync() { + const currentlySetPath = this.props.blockchain.getLedgerDerivationPathIfExists(); + if (currentlySetPath === this.state.derivationPath) { + return; + } + this.props.blockchain.updateLedgerDerivationPathIfExists(this.state.derivationPath); + const didSucceed = await this.fetchAddressesAndBalancesAsync(); + if (!didSucceed) { + this.setState({ + derivationErrMsg: 'Failed to connect to Ledger.', + }); + } + return didSucceed; + } + private async fetchAddressesAndBalancesAsync() { + let userAddresses: string[]; + const addressBalances: BigNumber[] = []; + try { + userAddresses = await this.getUserAddressesAsync(); + for (const address of userAddresses) { + const balance = await this.props.blockchain.getBalanceInEthAsync(address); + addressBalances.push(balance); + } + } catch (err) { + utils.consoleLog(`Ledger error: ${JSON.stringify(err)}`); + this.setState({ + didConnectFail: true, + }); + return false; + } + this.setState({ + userAddresses, + addressBalances, + }); + return true; + } + private onDerivationPathChanged(e: any, derivationPath: string) { + let derivationErrMsg = ''; + if (!_.startsWith(derivationPath, VALID_ETHEREUM_DERIVATION_PATH_PREFIX)) { + derivationErrMsg = 'Must be valid Ethereum path.'; + } + + this.setState({ + derivationPath, + derivationErrMsg, + }); + } + private async onConnectLedgerClickAsync() { + const didSucceed = await this.fetchAddressesAndBalancesAsync(); + if (didSucceed) { + this.setState({ + stepIndex: LedgerSteps.SELECT_ADDRESS, + }); + } + return didSucceed; + } + private async getUserAddressesAsync(): Promise { + let userAddresses: string[]; + userAddresses = await this.props.blockchain.getUserAccountsAsync(); + + if (_.isEmpty(userAddresses)) { + throw new Error('No addresses retrieved.'); + } + return userAddresses; + } +} diff --git a/packages/website/ts/components/dialogs/portal_disclaimer_dialog.tsx b/packages/website/ts/components/dialogs/portal_disclaimer_dialog.tsx new file mode 100644 index 000000000..8f870b42f --- /dev/null +++ b/packages/website/ts/components/dialogs/portal_disclaimer_dialog.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import FlatButton from 'material-ui/FlatButton'; +import Dialog from 'material-ui/Dialog'; +import {constants} from 'ts/utils/constants'; + +interface PortalDisclaimerDialogProps { + isOpen: boolean; + onToggleDialog: () => void; +} + +export function PortalDisclaimerDialog(props: PortalDisclaimerDialogProps) { + return ( + , + ]} + open={props.isOpen} + onRequestClose={props.onToggleDialog.bind(this)} + autoScrollBodyContent={true} + modal={true} + > +
+
+ 0x Portal is a free software-based tool intended to help users to + buy and sell ERC20-compatible blockchain tokens through the 0x protocol + on a purely peer-to-peer basis. 0x portal is not a regulated marketplace, + exchange or intermediary of any kind, and therefore, you should only use + 0x portal to exchange tokens that are not securities, commodity interests, + or any other form of regulated instrument. 0x has not attempted to screen + or otherwise limit the tokens that you may enter in 0x Portal. By clicking + “I Agree” below, you understand that you are solely responsible for using 0x + Portal and buying and selling tokens using 0x Portal in compliance with all + applicable laws and regulations. +
+
+
+ ); +} diff --git a/packages/website/ts/components/dialogs/send_dialog.tsx b/packages/website/ts/components/dialogs/send_dialog.tsx new file mode 100644 index 000000000..10417a326 --- /dev/null +++ b/packages/website/ts/components/dialogs/send_dialog.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import Dialog from 'material-ui/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import RadioButtonGroup from 'material-ui/RadioButton/RadioButtonGroup'; +import RadioButton from 'material-ui/RadioButton'; +import {Side, Token, TokenState} from 'ts/types'; +import {TokenAmountInput} from 'ts/components/inputs/token_amount_input'; +import {EthAmountInput} from 'ts/components/inputs/eth_amount_input'; +import {AddressInput} from 'ts/components/inputs/address_input'; +import BigNumber from 'bignumber.js'; + +interface SendDialogProps { + onComplete: (recipient: string, value: BigNumber) => void; + onCancelled: () => void; + isOpen: boolean; + token: Token; + tokenState: TokenState; +} + +interface SendDialogState { + value?: BigNumber; + recipient: string; + shouldShowIncompleteErrs: boolean; + isAmountValid: boolean; +} + +export class SendDialog extends React.Component { + constructor() { + super(); + this.state = { + recipient: '', + shouldShowIncompleteErrs: false, + isAmountValid: false, + }; + } + public render() { + const transferDialogActions = [ + , + , + ]; + return ( + + {this.renderSendDialogBody()} + + ); + } + private renderSendDialogBody() { + return ( +
+
+ +
+ +
+ ); + } + private onRecipientChange(recipient?: string) { + this.setState({ + shouldShowIncompleteErrs: false, + recipient, + }); + } + private onValueChange(isValid: boolean, amount?: BigNumber) { + this.setState({ + isAmountValid: isValid, + value: amount, + }); + } + private onSendClick() { + if (this.hasErrors()) { + this.setState({ + shouldShowIncompleteErrs: true, + }); + } else { + const value = this.state.value; + this.setState({ + recipient: undefined, + value: undefined, + }); + this.props.onComplete(this.state.recipient, value); + } + } + private onCancel() { + this.setState({ + value: undefined, + }); + this.props.onCancelled(); + } + private hasErrors() { + return _.isUndefined(this.state.recipient) || + _.isUndefined(this.state.value) || + !this.state.isAmountValid; + } +} diff --git a/packages/website/ts/components/dialogs/track_token_confirmation_dialog.tsx b/packages/website/ts/components/dialogs/track_token_confirmation_dialog.tsx new file mode 100644 index 000000000..97c654656 --- /dev/null +++ b/packages/website/ts/components/dialogs/track_token_confirmation_dialog.tsx @@ -0,0 +1,99 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import FlatButton from 'material-ui/FlatButton'; +import Dialog from 'material-ui/Dialog'; +import {constants} from 'ts/utils/constants'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {TrackTokenConfirmation} from 'ts/components/track_token_confirmation'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; +import {Token, TokenByAddress} from 'ts/types'; + +interface TrackTokenConfirmationDialogProps { + tokens: Token[]; + tokenByAddress: TokenByAddress; + isOpen: boolean; + onToggleDialog: (didConfirmTokenTracking: boolean) => void; + dispatcher: Dispatcher; + networkId: number; + blockchain: Blockchain; + userAddress: string; +} + +interface TrackTokenConfirmationDialogState { + isAddingTokenToTracked: boolean; +} + +export class TrackTokenConfirmationDialog extends + React.Component { + constructor(props: TrackTokenConfirmationDialogProps) { + super(props); + this.state = { + isAddingTokenToTracked: false, + }; + } + public render() { + const tokens = this.props.tokens; + return ( + , + , + ]} + open={this.props.isOpen} + onRequestClose={this.props.onToggleDialog.bind(this, false)} + autoScrollBodyContent={true} + > +
+ +
+
+ ); + } + private async onTrackConfirmationRespondedAsync(didUserAcceptTracking: boolean) { + if (!didUserAcceptTracking) { + this.props.onToggleDialog(didUserAcceptTracking); + return; + } + this.setState({ + isAddingTokenToTracked: true, + }); + for (const token of this.props.tokens) { + const newTokenEntry = _.assign({}, token); + + newTokenEntry.isTracked = true; + trackedTokenStorage.addTrackedTokenToUser(this.props.userAddress, this.props.networkId, newTokenEntry); + this.props.dispatcher.updateTokenByAddress([newTokenEntry]); + + const [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(token.address); + this.props.dispatcher.updateTokenStateByAddress({ + [token.address]: { + balance, + allowance, + }, + }); + } + + this.setState({ + isAddingTokenToTracked: false, + }); + this.props.onToggleDialog(didUserAcceptTracking); + } +} diff --git a/packages/website/ts/components/dialogs/u2f_not_supported_dialog.tsx b/packages/website/ts/components/dialogs/u2f_not_supported_dialog.tsx new file mode 100644 index 000000000..28c24cdbe --- /dev/null +++ b/packages/website/ts/components/dialogs/u2f_not_supported_dialog.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import FlatButton from 'material-ui/FlatButton'; +import Dialog from 'material-ui/Dialog'; +import {constants} from 'ts/utils/constants'; + +interface U2fNotSupportedDialogProps { + isOpen: boolean; + onToggleDialog: () => void; +} + +export function U2fNotSupportedDialog(props: U2fNotSupportedDialogProps) { + return ( + , + ]} + open={props.isOpen} + onRequestClose={props.onToggleDialog.bind(this)} + autoScrollBodyContent={true} + > +
+
+ It looks like your browser does not support U2F connections + required for us to communicate with your hardware wallet. + Please use a browser that supports U2F connections and try + again. +
+
+
    +
  • Chrome version 38 or later
  • +
  • Opera version 40 of later
  • +
  • + Firefox with{' '} + + this extension + . +
  • +
+
+
+
+ ); +} diff --git a/packages/website/ts/components/eth_weth_conversion_button.tsx b/packages/website/ts/components/eth_weth_conversion_button.tsx new file mode 100644 index 000000000..fd8b713f4 --- /dev/null +++ b/packages/website/ts/components/eth_weth_conversion_button.tsx @@ -0,0 +1,101 @@ +import * as _ from 'lodash'; +import {ZeroEx} from '0x.js'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import RaisedButton from 'material-ui/RaisedButton'; +import {BlockchainCallErrs, TokenState} from 'ts/types'; +import {EthWethConversionDialog} from 'ts/components/dialogs/eth_weth_conversion_dialog'; +import {Side, Token} from 'ts/types'; +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {Blockchain} from 'ts/blockchain'; + +interface EthWethConversionButtonProps { + ethToken: Token; + ethTokenState: TokenState; + dispatcher: Dispatcher; + blockchain: Blockchain; + userEtherBalance: BigNumber; + onError: () => void; +} + +interface EthWethConversionButtonState { + isEthConversionDialogVisible: boolean; + isEthConversionHappening: boolean; +} + +export class EthWethConversionButton extends + React.Component { + public constructor(props: EthWethConversionButtonProps) { + super(props); + this.state = { + isEthConversionDialogVisible: false, + isEthConversionHappening: false, + }; + } + public render() { + const labelStyle = this.state.isEthConversionHappening ? {fontSize: 10} : {}; + return ( +
+ + +
+ ); + } + private toggleConversionDialog() { + this.setState({ + isEthConversionDialogVisible: !this.state.isEthConversionDialogVisible, + }); + } + private async onConversionAmountSelectedAsync(direction: Side, value: BigNumber) { + this.setState({ + isEthConversionHappening: true, + }); + this.toggleConversionDialog(); + const token = this.props.ethToken; + const tokenState = this.props.ethTokenState; + let balance = tokenState.balance; + try { + if (direction === Side.deposit) { + await this.props.blockchain.convertEthToWrappedEthTokensAsync(value); + const ethAmount = ZeroEx.toUnitAmount(value, constants.ETH_DECIMAL_PLACES); + this.props.dispatcher.showFlashMessage(`Successfully converted ${ethAmount.toString()} ETH to WETH`); + balance = balance.plus(value); + } else { + await this.props.blockchain.convertWrappedEthTokensToEthAsync(value); + const tokenAmount = ZeroEx.toUnitAmount(value, token.decimals); + this.props.dispatcher.showFlashMessage(`Successfully converted ${tokenAmount.toString()} WETH to ETH`); + balance = balance.minus(value); + } + this.props.dispatcher.replaceTokenBalanceByAddress(token.address, balance); + } catch (err) { + const errMsg = '' + err; + if (_.includes(errMsg, BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + } else if (!_.includes(errMsg, 'User denied transaction')) { + utils.consoleLog(`Unexpected error encountered: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + this.props.onError(); + } + } + this.setState({ + isEthConversionHappening: false, + }); + } +} diff --git a/packages/website/ts/components/fill_order.tsx b/packages/website/ts/components/fill_order.tsx new file mode 100644 index 000000000..dc965283e --- /dev/null +++ b/packages/website/ts/components/fill_order.tsx @@ -0,0 +1,714 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as accounting from 'accounting'; +import {Link} from 'react-router-dom'; +import {ZeroEx, Order as ZeroExOrder} from '0x.js'; +import * as moment from 'moment'; +import BigNumber from 'bignumber.js'; +import Paper from 'material-ui/Paper'; +import {Card, CardText, CardHeader} from 'material-ui/Card'; +import Divider from 'material-ui/Divider'; +import TextField from 'material-ui/TextField'; +import RaisedButton from 'material-ui/RaisedButton'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import { + Side, + TokenByAddress, + TokenStateByAddress, + Order, + BlockchainErrs, + OrderToken, + Token, + ExchangeContractErrs, + AlertTypes, + ContractResponse, + WebsitePaths, +} from 'ts/types'; +import {Alert} from 'ts/components/ui/alert'; +import {Identicon} from 'ts/components/ui/identicon'; +import {EthereumAddress} from 'ts/components/ui/ethereum_address'; +import {TokenAmountInput} from 'ts/components/inputs/token_amount_input'; +import {FillWarningDialog} from 'ts/components/fill_warning_dialog'; +import {FillOrderJSON} from 'ts/components/fill_order_json'; +import {VisualOrder} from 'ts/components/visual_order'; +import {SchemaValidator} from 'ts/schemas/validator'; +import {orderSchema} from 'ts/schemas/order_schema'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {Blockchain} from 'ts/blockchain'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; +import {TrackTokenConfirmationDialog} from 'ts/components/dialogs/track_token_confirmation_dialog'; + +const CUSTOM_LIGHT_GRAY = '#BBBBBB'; + +interface FillOrderProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + orderFillAmount: BigNumber; + isOrderInUrl: boolean; + networkId: number; + userAddress: string; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; + initialOrder: Order; + dispatcher: Dispatcher; +} + +interface FillOrderState { + didOrderValidationRun: boolean; + areAllInvolvedTokensTracked: boolean; + globalErrMsg: string; + orderJSON: string; + orderJSONErrMsg: string; + parsedOrder: Order; + didFillOrderSucceed: boolean; + didCancelOrderSucceed: boolean; + unavailableTakerAmount: BigNumber; + isMakerTokenAddressInRegistry: boolean; + isTakerTokenAddressInRegistry: boolean; + isFillWarningDialogOpen: boolean; + isFilling: boolean; + isCancelling: boolean; + isConfirmingTokenTracking: boolean; + tokensToTrack: Token[]; +} + +export class FillOrder extends React.Component { + private validator: SchemaValidator; + constructor(props: FillOrderProps) { + super(props); + this.state = { + globalErrMsg: '', + didOrderValidationRun: false, + areAllInvolvedTokensTracked: false, + didFillOrderSucceed: false, + didCancelOrderSucceed: false, + orderJSON: _.isUndefined(this.props.initialOrder) ? '' : JSON.stringify(this.props.initialOrder), + orderJSONErrMsg: '', + parsedOrder: this.props.initialOrder, + unavailableTakerAmount: new BigNumber(0), + isMakerTokenAddressInRegistry: false, + isTakerTokenAddressInRegistry: false, + isFillWarningDialogOpen: false, + isFilling: false, + isCancelling: false, + isConfirmingTokenTracking: false, + tokensToTrack: [], + }; + this.validator = new SchemaValidator(); + } + public componentWillMount() { + if (!_.isEmpty(this.state.orderJSON)) { + this.validateFillOrderFireAndForgetAsync(this.state.orderJSON); + } + } + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + return ( +
+

Fill an order

+ +
+ {!this.props.isOrderInUrl && +
+
+ Paste an order JSON snippet below to begin +
+
Order JSON
+ + {this.renderOrderJsonNotices()} +
+ } +
+ {!_.isUndefined(this.state.parsedOrder) && this.state.didOrderValidationRun + && this.state.areAllInvolvedTokensTracked && + this.renderVisualOrder() + } +
+ {this.props.isOrderInUrl && +
+ + + + + + + {this.renderOrderJsonNotices()} +
+ } +
+ + +
+ ); + } + private renderOrderJsonNotices() { + return ( +
+ {!_.isUndefined(this.props.initialOrder) && !this.state.didOrderValidationRun && +
+ + + + Validating order... +
+ } + {!_.isEmpty(this.state.orderJSONErrMsg) && + + } +
+ ); + } + private renderVisualOrder() { + const takerTokenAddress = this.state.parsedOrder.taker.token.address; + const takerToken = this.props.tokenByAddress[takerTokenAddress]; + const orderTakerAmount = new BigNumber(this.state.parsedOrder.taker.amount); + const orderMakerAmount = new BigNumber(this.state.parsedOrder.maker.amount); + const takerAssetToken = { + amount: orderTakerAmount.minus(this.state.unavailableTakerAmount), + symbol: takerToken.symbol, + }; + const fillToken = this.props.tokenByAddress[takerToken.address]; + const fillTokenState = this.props.tokenStateByAddress[takerToken.address]; + const makerTokenAddress = this.state.parsedOrder.maker.token.address; + const makerToken = this.props.tokenByAddress[makerTokenAddress]; + const makerAssetToken = { + amount: orderMakerAmount.times(takerAssetToken.amount).div(orderTakerAmount), + symbol: makerToken.symbol, + }; + const fillAssetToken = { + amount: this.props.orderFillAmount, + symbol: takerToken.symbol, + }; + const orderTaker = !_.isEmpty(this.state.parsedOrder.taker.address) ? this.state.parsedOrder.taker.address : + this.props.userAddress; + const parsedOrderExpiration = new BigNumber(this.state.parsedOrder.expiration); + const exchangeRate = orderMakerAmount.div(orderTakerAmount); + + let orderReceiveAmount = 0; + if (!_.isUndefined(this.props.orderFillAmount)) { + const orderReceiveAmountBigNumber = exchangeRate.mul(this.props.orderFillAmount); + orderReceiveAmount = this.formatCurrencyAmount(orderReceiveAmountBigNumber, makerToken.decimals); + } + const isUserMaker = !_.isUndefined(this.state.parsedOrder) && + this.state.parsedOrder.maker.address === this.props.userAddress; + const expiryDate = utils.convertToReadableDateTimeFromUnixTimestamp(parsedOrderExpiration); + return ( +
+
+
Order details
+
+
+ Maker: +
+
+ +
+
+ +
+
+
+
+
+ +
+ Expires: {expiryDate} UTC +
+
+
+ {!isUserMaker && +
+
+ +
+
+ = {accounting.formatNumber(orderReceiveAmount, 6)} {makerToken.symbol} +
+
+ } +
+ {isUserMaker ? +
+ + {this.state.didCancelOrderSucceed && + + } +
: +
+ + {!_.isEmpty(this.state.globalErrMsg) && + + } + {this.state.didFillOrderSucceed && + + } +
+ } +
+
+ ); + } + private renderFillSuccessMsg() { + return ( +
+ Order successfully filled. See the trade details in your{' '} + + trade history + +
+ ); + } + private renderCancelSuccessMsg() { + return ( +
+ Order successfully cancelled. +
+ ); + } + private onFillOrderClick() { + if (!this.state.isMakerTokenAddressInRegistry || !this.state.isTakerTokenAddressInRegistry) { + this.setState({ + isFillWarningDialogOpen: true, + }); + } else { + this.onFillOrderClickFireAndForgetAsync(); + } + } + private onFillWarningClosed(didUserCancel: boolean) { + this.setState({ + isFillWarningDialogOpen: false, + }); + if (!didUserCancel) { + this.onFillOrderClickFireAndForgetAsync(); + } + } + private onFillAmountChange(isValid: boolean, amount?: BigNumber) { + this.props.dispatcher.updateOrderFillAmount(amount); + } + private onFillOrderJSONChanged(event: any) { + const orderJSON = event.target.value; + this.setState({ + didOrderValidationRun: _.isEmpty(orderJSON) && _.isEmpty(this.state.orderJSONErrMsg), + didFillOrderSucceed: false, + }); + this.validateFillOrderFireAndForgetAsync(orderJSON); + } + private async checkForUntrackedTokensAndAskToAdd() { + if (!_.isEmpty(this.state.orderJSONErrMsg)) { + return; + } + + const makerTokenIfExists = this.props.tokenByAddress[this.state.parsedOrder.maker.token.address]; + const takerTokenIfExists = this.props.tokenByAddress[this.state.parsedOrder.taker.token.address]; + + const tokensToTrack = []; + const isUnseenMakerToken = _.isUndefined(makerTokenIfExists); + const isMakerTokenTracked = !_.isUndefined(makerTokenIfExists) && makerTokenIfExists.isTracked; + if (isUnseenMakerToken) { + tokensToTrack.push(_.assign({}, this.state.parsedOrder.maker.token, { + iconUrl: undefined, + isTracked: false, + isRegistered: false, + })); + } else if (!isMakerTokenTracked) { + tokensToTrack.push(makerTokenIfExists); + } + const isUnseenTakerToken = _.isUndefined(takerTokenIfExists); + const isTakerTokenTracked = !_.isUndefined(takerTokenIfExists) && takerTokenIfExists.isTracked; + if (isUnseenTakerToken) { + tokensToTrack.push(_.assign({}, this.state.parsedOrder.taker.token, { + iconUrl: undefined, + isTracked: false, + isRegistered: false, + })); + } else if (!isTakerTokenTracked) { + tokensToTrack.push(takerTokenIfExists); + } + if (!_.isEmpty(tokensToTrack)) { + this.setState({ + isConfirmingTokenTracking: true, + tokensToTrack, + }); + } else { + this.setState({ + areAllInvolvedTokensTracked: true, + }); + } + } + private async validateFillOrderFireAndForgetAsync(orderJSON: string) { + let orderJSONErrMsg = ''; + let parsedOrder: Order; + try { + const order = JSON.parse(orderJSON); + const validationResult = this.validator.validate(order, orderSchema); + if (validationResult.errors.length > 0) { + orderJSONErrMsg = 'Submitted order JSON is not a valid order'; + utils.consoleLog(`Unexpected order JSON validation error: ${validationResult.errors.join(', ')}`); + return; + } + parsedOrder = order; + + const exchangeContractAddr = this.props.blockchain.getExchangeContractAddressIfExists(); + const makerAmount = new BigNumber(parsedOrder.maker.amount); + const takerAmount = new BigNumber(parsedOrder.taker.amount); + const expiration = new BigNumber(parsedOrder.expiration); + const salt = new BigNumber(parsedOrder.salt); + const parsedMakerFee = new BigNumber(parsedOrder.maker.feeAmount); + const parsedTakerFee = new BigNumber(parsedOrder.taker.feeAmount); + + const zeroExOrder: ZeroExOrder = { + exchangeContractAddress: parsedOrder.exchangeContract, + expirationUnixTimestampSec: expiration, + feeRecipient: parsedOrder.feeRecipient, + maker: parsedOrder.maker.address, + makerFee: parsedMakerFee, + makerTokenAddress: parsedOrder.maker.token.address, + makerTokenAmount: makerAmount, + salt, + taker: _.isEmpty(parsedOrder.taker.address) ? constants.NULL_ADDRESS : parsedOrder.taker.address, + takerFee: parsedTakerFee, + takerTokenAddress: parsedOrder.taker.token.address, + takerTokenAmount: takerAmount, + }; + const orderHash = ZeroEx.getOrderHashHex(zeroExOrder); + + const signature = parsedOrder.signature; + const isValidSignature = ZeroEx.isValidSignature(signature.hash, signature, parsedOrder.maker.address); + if (this.props.networkId !== parsedOrder.networkId) { + orderJSONErrMsg = `This order was made on another Ethereum network + (id: ${parsedOrder.networkId}). Connect to this network to fill.`; + parsedOrder = undefined; + } else if (exchangeContractAddr !== parsedOrder.exchangeContract) { + orderJSONErrMsg = 'This order was made using a deprecated 0x Exchange contract.'; + parsedOrder = undefined; + } else if (orderHash !== signature.hash) { + orderJSONErrMsg = 'Order hash does not match supplied plaintext values'; + parsedOrder = undefined; + } else if (!isValidSignature) { + orderJSONErrMsg = 'Order signature is invalid'; + parsedOrder = undefined; + } else { + // Update user supplied order cache so that if they navigate away from fill view + // e.g to set a token allowance, when they come back, the fill order persists + this.props.dispatcher.updateUserSuppliedOrderCache(parsedOrder); + } + } catch (err) { + utils.consoleLog(`Validate order err: ${err}`); + if (!_.isEmpty(orderJSON)) { + orderJSONErrMsg = 'Submitted order JSON is not valid JSON'; + } + this.setState({ + didOrderValidationRun: true, + orderJSON, + orderJSONErrMsg, + parsedOrder, + }); + return; + } + + let unavailableTakerAmount = new BigNumber(0); + if (!_.isEmpty(orderJSONErrMsg)) { + // Clear cache entry if user updates orderJSON to invalid entry + this.props.dispatcher.updateUserSuppliedOrderCache(undefined); + } else { + const orderHash = parsedOrder.signature.hash; + unavailableTakerAmount = await this.props.blockchain.getUnavailableTakerAmountAsync(orderHash); + const isMakerTokenAddressInRegistry = await this.props.blockchain.isAddressInTokenRegistryAsync( + parsedOrder.maker.token.address, + ); + const isTakerTokenAddressInRegistry = await this.props.blockchain.isAddressInTokenRegistryAsync( + parsedOrder.taker.token.address, + ); + this.setState({ + isMakerTokenAddressInRegistry, + isTakerTokenAddressInRegistry, + }); + } + + this.setState({ + didOrderValidationRun: true, + orderJSON, + orderJSONErrMsg, + parsedOrder, + unavailableTakerAmount, + }); + + await this.checkForUntrackedTokensAndAskToAdd(); + } + private async onFillOrderClickFireAndForgetAsync(): Promise { + if (!_.isEmpty(this.props.blockchainErr) || _.isEmpty(this.props.userAddress)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return; + } + + this.setState({ + isFilling: true, + didFillOrderSucceed: false, + }); + + const parsedOrder = this.state.parsedOrder; + const orderHash = parsedOrder.signature.hash; + const unavailableTakerAmount = await this.props.blockchain.getUnavailableTakerAmountAsync(orderHash); + const takerFillAmount = this.props.orderFillAmount; + + if (_.isUndefined(this.props.userAddress)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + this.setState({ + isFilling: false, + }); + return; + } + let globalErrMsg = ''; + + if (_.isUndefined(takerFillAmount)) { + globalErrMsg = 'You must specify a fill amount'; + } + + const signedOrder = this.props.blockchain.portalOrderToSignedOrder( + parsedOrder.maker.address, + parsedOrder.taker.address, + parsedOrder.maker.token.address, + parsedOrder.taker.token.address, + new BigNumber(parsedOrder.maker.amount), + new BigNumber(parsedOrder.taker.amount), + new BigNumber(parsedOrder.maker.feeAmount), + new BigNumber(parsedOrder.taker.feeAmount), + new BigNumber(this.state.parsedOrder.expiration), + parsedOrder.feeRecipient, + parsedOrder.signature, + new BigNumber(parsedOrder.salt), + ); + if (_.isEmpty(globalErrMsg)) { + try { + await this.props.blockchain.validateFillOrderThrowIfInvalidAsync( + signedOrder, takerFillAmount, this.props.userAddress); + } catch (err) { + globalErrMsg = this.props.blockchain.toHumanReadableErrorMsg(err.message, parsedOrder.taker.address); + } + } + if (!_.isEmpty(globalErrMsg)) { + this.setState({ + isFilling: false, + globalErrMsg, + }); + return; + } + try { + const orderFilledAmount: BigNumber = await this.props.blockchain.fillOrderAsync( + signedOrder, this.props.orderFillAmount, + ); + // After fill completes, let's update the token balances + const makerToken = this.props.tokenByAddress[parsedOrder.maker.token.address]; + const takerToken = this.props.tokenByAddress[parsedOrder.taker.token.address]; + const tokens = [makerToken, takerToken]; + await this.props.blockchain.updateTokenBalancesAndAllowancesAsync(tokens); + this.setState({ + isFilling: false, + didFillOrderSucceed: true, + globalErrMsg: '', + unavailableTakerAmount: this.state.unavailableTakerAmount.plus(orderFilledAmount), + }); + return; + } catch (err) { + this.setState({ + isFilling: false, + }); + const errMsg = `${err}`; + if (_.includes(errMsg, 'User denied transaction signature')) { + return; + } + globalErrMsg = 'Failed to fill order, please refresh and try again'; + utils.consoleLog(`${err}`); + await errorReporter.reportAsync(err); + this.setState({ + globalErrMsg, + }); + return; + } + } + private async onCancelOrderClickFireAndForgetAsync(): Promise { + if (!_.isEmpty(this.props.blockchainErr) || _.isEmpty(this.props.userAddress)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return; + } + + this.setState({ + isCancelling: true, + didCancelOrderSucceed: false, + }); + + const parsedOrder = this.state.parsedOrder; + const orderHash = parsedOrder.signature.hash; + const takerAddress = this.props.userAddress; + + if (_.isUndefined(takerAddress)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + this.setState({ + isFilling: false, + }); + return; + } + let globalErrMsg = ''; + + const takerTokenAmount = new BigNumber(parsedOrder.taker.amount); + + const signedOrder = this.props.blockchain.portalOrderToSignedOrder( + parsedOrder.maker.address, + parsedOrder.taker.address, + parsedOrder.maker.token.address, + parsedOrder.taker.token.address, + new BigNumber(parsedOrder.maker.amount), + takerTokenAmount, + new BigNumber(parsedOrder.maker.feeAmount), + new BigNumber(parsedOrder.taker.feeAmount), + new BigNumber(this.state.parsedOrder.expiration), + parsedOrder.feeRecipient, + parsedOrder.signature, + new BigNumber(parsedOrder.salt), + ); + const unavailableTakerAmount = await this.props.blockchain.getUnavailableTakerAmountAsync(orderHash); + const availableTakerTokenAmount = takerTokenAmount.minus(unavailableTakerAmount); + try { + await this.props.blockchain.validateCancelOrderThrowIfInvalidAsync( + signedOrder, availableTakerTokenAmount); + } catch (err) { + globalErrMsg = this.props.blockchain.toHumanReadableErrorMsg(err.message, parsedOrder.taker.address); + } + if (!_.isEmpty(globalErrMsg)) { + this.setState({ + isCancelling: false, + globalErrMsg, + }); + return; + } + try { + await this.props.blockchain.cancelOrderAsync( + signedOrder, availableTakerTokenAmount, + ); + this.setState({ + isCancelling: false, + didCancelOrderSucceed: true, + globalErrMsg: '', + unavailableTakerAmount: takerTokenAmount, + }); + return; + } catch (err) { + this.setState({ + isCancelling: false, + }); + const errMsg = `${err}`; + if (_.includes(errMsg, 'User denied transaction signature')) { + return; + } + globalErrMsg = 'Failed to cancel order, please refresh and try again'; + utils.consoleLog(`${err}`); + await errorReporter.reportAsync(err); + this.setState({ + globalErrMsg, + }); + return; + } + } + private formatCurrencyAmount(amount: BigNumber, decimals: number): number { + const unitAmount = ZeroEx.toUnitAmount(amount, decimals); + const roundedUnitAmount = Math.round(unitAmount.toNumber() * 100000) / 100000; + return roundedUnitAmount; + } + private onToggleTrackConfirmDialog(didConfirmTokenTracking: boolean) { + if (!didConfirmTokenTracking) { + this.setState({ + orderJSON: '', + orderJSONErrMsg: '', + parsedOrder: undefined, + }); + } else { + this.setState({ + areAllInvolvedTokensTracked: true, + }); + } + this.setState({ + isConfirmingTokenTracking: !this.state.isConfirmingTokenTracking, + tokensToTrack: [], + }); + } +} diff --git a/packages/website/ts/components/fill_order_json.tsx b/packages/website/ts/components/fill_order_json.tsx new file mode 100644 index 000000000..b355d910b --- /dev/null +++ b/packages/website/ts/components/fill_order_json.tsx @@ -0,0 +1,69 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import {ZeroEx} from '0x.js'; +import Paper from 'material-ui/Paper'; +import TextField from 'material-ui/TextField'; +import {Side, TokenByAddress} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {Blockchain} from 'ts/blockchain'; +import {constants} from 'ts/utils/constants'; + +interface FillOrderJSONProps { + blockchain: Blockchain; + tokenByAddress: TokenByAddress; + networkId: number; + orderJSON: string; + onFillOrderJSONChanged: (event: any) => void; +} + +interface FillOrderJSONState {} + +export class FillOrderJSON extends React.Component { + public render() { + const tokenAddresses = _.keys(this.props.tokenByAddress); + const exchangeContract = this.props.blockchain.getExchangeContractAddressIfExists(); + const hintSideToAssetToken = { + [Side.deposit]: { + amount: new BigNumber(35), + address: tokenAddresses[0], + }, + [Side.receive]: { + amount: new BigNumber(89), + address: tokenAddresses[1], + }, + }; + const hintOrderExpiryTimestamp = utils.initialOrderExpiryUnixTimestampSec(); + const hintSignatureData = { + hash: '0xf965a9978a0381ab58f5a2408ad967c...', + r: '0xf01103f759e2289a28593eaf22e5820032...', + s: '937862111edcba395f8a9e0cc1b2c5e12320...', + v: 27, + }; + const hintSalt = ZeroEx.generatePseudoRandomSalt(); + const hintOrder = utils.generateOrder(this.props.networkId, exchangeContract, hintSideToAssetToken, + hintOrderExpiryTimestamp, '', '', constants.MAKER_FEE, + constants.TAKER_FEE, constants.FEE_RECIPIENT_ADDRESS, + hintSignatureData, this.props.tokenByAddress, hintSalt); + const hintOrderJSON = `${JSON.stringify(hintOrder, null, '\t').substring(0, 500)}...`; + return ( +
+ + + +
+ ); + } +} diff --git a/packages/website/ts/components/fill_warning_dialog.tsx b/packages/website/ts/components/fill_warning_dialog.tsx new file mode 100644 index 000000000..029fa8b0c --- /dev/null +++ b/packages/website/ts/components/fill_warning_dialog.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import FlatButton from 'material-ui/FlatButton'; +import Dialog from 'material-ui/Dialog'; + +interface FillWarningDialogProps { + isOpen: boolean; + onToggleDialog: () => void; +} + +export function FillWarningDialog(props: FillWarningDialogProps) { + const didCancel = true; + return ( + , + , + ]} + open={props.isOpen} + onRequestClose={props.onToggleDialog.bind(this)} + autoScrollBodyContent={true} + modal={true} + > +
+
+ At least one of the tokens in this order was not found in the + token registry smart contract and may be counterfeit. It is your + responsibility to verify the token addresses on Etherscan ( + + See this how-to guide + ) before filling an order. This action may result in the loss of funds. +
+
+
+ ); +} diff --git a/packages/website/ts/components/flash_messages/token_send_completed.tsx b/packages/website/ts/components/flash_messages/token_send_completed.tsx new file mode 100644 index 000000000..c4977d70b --- /dev/null +++ b/packages/website/ts/components/flash_messages/token_send_completed.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import BigNumber from 'bignumber.js'; +import {ZeroEx} from '0x.js'; +import {Token} from 'ts/types'; +import {utils} from 'ts/utils/utils'; + +interface TokenSendCompletedProps { + etherScanLinkIfExists?: string; + token: Token; + toAddress: string; + amountInBaseUnits: BigNumber; +} + +interface TokenSendCompletedState {} + +export class TokenSendCompleted extends React.Component { + public render() { + const etherScanLink = !_.isUndefined(this.props.etherScanLinkIfExists) && + ( + + Verify on Etherscan + + ); + const amountInUnits = ZeroEx.toUnitAmount(this.props.amountInBaseUnits, this.props.token.decimals); + const truncatedAddress = utils.getAddressBeginAndEnd(this.props.toAddress); + return ( +
+ {`Sent ${amountInUnits} ${this.props.token.symbol} to ${truncatedAddress}: `} + {etherScanLink} +
+ ); + } +} diff --git a/packages/website/ts/components/flash_messages/transaction_submitted.tsx b/packages/website/ts/components/flash_messages/transaction_submitted.tsx new file mode 100644 index 000000000..7a3cc6e86 --- /dev/null +++ b/packages/website/ts/components/flash_messages/transaction_submitted.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as _ from 'lodash'; + +interface TransactionSubmittedProps { + etherScanLinkIfExists?: string; +} + +interface TransactionSubmittedState {} + +export class TransactionSubmitted extends React.Component { + public render() { + if (_.isUndefined(this.props.etherScanLinkIfExists)) { + return
Transaction submitted to the network
; + } else { + return ( +
+ Transaction submitted to the network:{' '} + + Verify on Etherscan + +
+ ); + } + } +} diff --git a/packages/website/ts/components/footer.tsx b/packages/website/ts/components/footer.tsx new file mode 100644 index 000000000..99f97292e --- /dev/null +++ b/packages/website/ts/components/footer.tsx @@ -0,0 +1,255 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {HashLink} from 'react-router-hash-link'; +import {Styles, WebsitePaths} from 'ts/types'; +import { + Link, +} from 'react-router-dom'; +import { + Link as ScrollLink, +} from 'react-scroll'; +import {constants} from 'ts/utils/constants'; + +interface MenuItemsBySection { + [sectionName: string]: FooterMenuItem[]; +} + +interface FooterMenuItem { + title: string; + path?: string; + isExternal?: boolean; + fileName?: string; +} + +enum Sections { + Documentation = 'Documentation', + Community = 'Community', + Organization = 'Organization', +} + +const ICON_DIMENSION = 16; +const CUSTOM_DARK_GRAY = '#393939'; +const CUSTOM_LIGHT_GRAY = '#CACACA'; +const CUSTOM_LIGHTEST_GRAY = '#9E9E9E'; +const menuItemsBySection: MenuItemsBySection = { + Documentation: [ + { + title: '0x.js', + path: WebsitePaths.ZeroExJs, + }, + { + title: '0x Smart Contracts', + path: WebsitePaths.SmartContracts, + }, + { + title: 'Whitepaper', + path: WebsitePaths.Whitepaper, + isExternal: true, + }, + { + title: 'Wiki', + path: WebsitePaths.Wiki, + }, + { + title: 'FAQ', + path: WebsitePaths.FAQ, + }, + ], + Community: [ + { + title: 'Rocket.chat', + isExternal: true, + path: constants.ZEROEX_CHAT_URL, + fileName: 'rocketchat.png', + }, + { + title: 'Blog', + isExternal: true, + path: constants.BLOG_URL, + fileName: 'medium.png', + }, + { + title: 'Twitter', + isExternal: true, + path: constants.TWITTER_URL, + fileName: 'twitter.png', + }, + { + title: 'Reddit', + isExternal: true, + path: constants.REDDIT_URL, + fileName: 'reddit.png', + }, + ], + Organization: [ + { + title: 'About', + isExternal: false, + path: WebsitePaths.About, + }, + { + title: 'Careers', + isExternal: true, + path: constants.ANGELLIST_URL, + }, + { + title: 'Contact', + isExternal: true, + path: 'mailto:team@0xproject.com', + }, + ], +}; +const linkStyle = { + color: 'white', + cursor: 'pointer', +}; + +const titleToIcon: {[title: string]: string} = { + 'Rocket.chat': 'rocketchat.png', + 'Blog': 'medium.png', + 'Twitter': 'twitter.png', + 'Reddit': 'reddit.png', +}; + +export interface FooterProps { + location: Location; +} + +interface FooterState {} + +export class Footer extends React.Component { + public render() { + return ( +
+
+
+
+
+ +
+
+ © ZeroEx, Intl. +
+
+
+
+
+
+ {this.renderHeader(Sections.Documentation)} + {_.map(menuItemsBySection[Sections.Documentation], this.renderMenuItem.bind(this))} +
+
+
+
+ {this.renderHeader(Sections.Community)} + {_.map(menuItemsBySection[Sections.Community], this.renderMenuItem.bind(this))} +
+
+
+
+ {this.renderHeader(Sections.Organization)} + {_.map(menuItemsBySection[Sections.Organization], this.renderMenuItem.bind(this))} +
+
+
+
+
+ ); + } + private renderIcon(fileName: string) { + return ( +
+ +
+ ); + } + private renderMenuItem(item: FooterMenuItem) { + const iconIfExists = titleToIcon[item.title]; + return ( +
+ {item.isExternal ? + + {!_.isUndefined(iconIfExists) ? +
+
+
+ {this.renderIcon(iconIfExists)} +
+
{item.title}
+
+
: + item.title + } +
: + +
+ {!_.isUndefined(iconIfExists) && +
+ {this.renderIcon(iconIfExists)} +
+ } + {item.title} +
+ + } +
+ ); + } + private renderHeader(title: string) { + const headerStyle = { + textTransform: 'uppercase', + color: CUSTOM_LIGHT_GRAY, + letterSpacing: 2, + fontFamily: 'Roboto Mono', + fontSize: 13, + }; + return ( +
+ {title} +
+ ); + } + private renderHomepageLink(title: string) { + const hash = title.toLowerCase(); + if (this.props.location.pathname === WebsitePaths.Home) { + return ( + + {title} + + ); + } else { + return ( + + {title} + + ); + } + } +} diff --git a/packages/website/ts/components/generate_order/asset_picker.tsx b/packages/website/ts/components/generate_order/asset_picker.tsx new file mode 100644 index 000000000..59826d06e --- /dev/null +++ b/packages/website/ts/components/generate_order/asset_picker.tsx @@ -0,0 +1,291 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import Dialog from 'material-ui/Dialog'; +import GridList from 'material-ui/GridList/GridList'; +import GridTile from 'material-ui/GridList/GridTile'; +import FlatButton from 'material-ui/FlatButton'; +import {utils} from 'ts/utils/utils'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + Token, + AssetToken, + TokenByAddress, + Styles, + TokenState, + DialogConfigs, + TokenVisibility, +} from 'ts/types'; +import {NewTokenForm} from 'ts/components/generate_order/new_token_form'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; +import {TrackTokenConfirmation} from 'ts/components/track_token_confirmation'; +import {TokenIcon} from 'ts/components/ui/token_icon'; + +const TOKEN_ICON_DIMENSION = 100; +const TILE_DIMENSION = 146; +enum AssetViews { + ASSET_PICKER = 'ASSET_PICKER', + NEW_TOKEN_FORM = 'NEW_TOKEN_FORM', + CONFIRM_TRACK_TOKEN = 'CONFIRM_TRACK_TOKEN', +} + +interface AssetPickerProps { + userAddress: string; + blockchain: Blockchain; + dispatcher: Dispatcher; + networkId: number; + isOpen: boolean; + currentTokenAddress: string; + onTokenChosen: (tokenAddress: string) => void; + tokenByAddress: TokenByAddress; + tokenVisibility?: TokenVisibility; +} + +interface AssetPickerState { + assetView: AssetViews; + hoveredAddress: string | undefined; + chosenTrackTokenAddress: string; + isAddingTokenToTracked: boolean; +} + +export class AssetPicker extends React.Component { + public static defaultProps: Partial = { + tokenVisibility: TokenVisibility.ALL, + }; + private dialogConfigsByAssetView: {[assetView: string]: DialogConfigs}; + constructor(props: AssetPickerProps) { + super(props); + this.state = { + assetView: AssetViews.ASSET_PICKER, + hoveredAddress: undefined, + chosenTrackTokenAddress: undefined, + isAddingTokenToTracked: false, + }; + this.dialogConfigsByAssetView = { + [AssetViews.ASSET_PICKER]: { + title: 'Select token', + isModal: false, + actions: [], + }, + [AssetViews.NEW_TOKEN_FORM]: { + title: 'Add an ERC20 token', + isModal: false, + actions: [], + }, + [AssetViews.CONFIRM_TRACK_TOKEN]: { + title: 'Tracking confirmation', + isModal: true, + actions: [ + , + , + ], + }, + }; + } + public render() { + const dialogConfigs: DialogConfigs = this.dialogConfigsByAssetView[this.state.assetView]; + return ( + + {this.state.assetView === AssetViews.ASSET_PICKER && + this.renderAssetPicker() + } + {this.state.assetView === AssetViews.NEW_TOKEN_FORM && + + } + {this.state.assetView === AssetViews.CONFIRM_TRACK_TOKEN && + this.renderConfirmTrackToken() + } + + ); + } + private renderConfirmTrackToken() { + const token = this.props.tokenByAddress[this.state.chosenTrackTokenAddress]; + return ( + + ); + } + private renderAssetPicker() { + return ( +
+ {this.renderGridTiles()} +
+ ); + } + private renderGridTiles() { + let isHovered; + let tileStyles; + const gridTiles = _.map(this.props.tokenByAddress, (token: Token, address: string) => { + if ((this.props.tokenVisibility === TokenVisibility.TRACKED && !token.isTracked) || + (this.props.tokenVisibility === TokenVisibility.UNTRACKED && token.isTracked)) { + return null; // Skip + } + isHovered = this.state.hoveredAddress === address; + tileStyles = { + cursor: 'pointer', + opacity: isHovered ? 0.6 : 1, + }; + return ( +
+
+ +
+
{token.name}
+
+ ); + }); + const otherTokenKey = 'otherToken'; + isHovered = this.state.hoveredAddress === otherTokenKey; + tileStyles = { + cursor: 'pointer', + opacity: isHovered ? 0.6 : 1, + }; + if (this.props.tokenVisibility !== TokenVisibility.TRACKED) { + gridTiles.push(( +
+
+ +
+
Other ERC20 Token
+
+ )); + } + return gridTiles; + } + private onToggleHover(address: string, isHovered: boolean) { + const hoveredAddress = isHovered ? address : undefined; + this.setState({ + hoveredAddress, + }); + } + private onCloseDialog() { + this.setState({ + assetView: AssetViews.ASSET_PICKER, + }); + this.props.onTokenChosen(this.props.currentTokenAddress); + } + private onChooseToken(tokenAddress: string) { + const token = this.props.tokenByAddress[tokenAddress]; + if (token.isTracked) { + this.props.onTokenChosen(tokenAddress); + } else { + this.setState({ + assetView: AssetViews.CONFIRM_TRACK_TOKEN, + chosenTrackTokenAddress: tokenAddress, + }); + } + } + private getTitle() { + switch (this.state.assetView) { + case AssetViews.ASSET_PICKER: + return 'Select token'; + + case AssetViews.NEW_TOKEN_FORM: + return 'Add an ERC20 token'; + + case AssetViews.CONFIRM_TRACK_TOKEN: + return 'Tracking confirmation'; + + default: + throw utils.spawnSwitchErr('assetView', this.state.assetView); + } + } + private onCustomAssetChosen() { + this.setState({ + assetView: AssetViews.NEW_TOKEN_FORM, + }); + } + private onNewTokenSubmitted(newToken: Token, newTokenState: TokenState) { + this.props.dispatcher.updateTokenStateByAddress({ + [newToken.address]: newTokenState, + }); + trackedTokenStorage.addTrackedTokenToUser(this.props.userAddress, this.props.networkId, newToken); + this.props.dispatcher.addTokenToTokenByAddress(newToken); + this.setState({ + assetView: AssetViews.ASSET_PICKER, + }); + this.props.onTokenChosen(newToken.address); + } + private async onTrackConfirmationRespondedAsync(didUserAcceptTracking: boolean) { + if (!didUserAcceptTracking) { + this.setState({ + isAddingTokenToTracked: false, + assetView: AssetViews.ASSET_PICKER, + chosenTrackTokenAddress: undefined, + }); + this.onCloseDialog(); + return; + } + this.setState({ + isAddingTokenToTracked: true, + }); + const tokenAddress = this.state.chosenTrackTokenAddress; + const token = this.props.tokenByAddress[tokenAddress]; + const newTokenEntry = _.assign({}, token); + + newTokenEntry.isTracked = true; + trackedTokenStorage.addTrackedTokenToUser(this.props.userAddress, this.props.networkId, newTokenEntry); + this.props.dispatcher.updateTokenByAddress([newTokenEntry]); + + const [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(token.address); + this.props.dispatcher.updateTokenStateByAddress({ + [token.address]: { + balance, + allowance, + }, + }); + this.setState({ + isAddingTokenToTracked: false, + assetView: AssetViews.ASSET_PICKER, + chosenTrackTokenAddress: undefined, + }); + this.props.onTokenChosen(tokenAddress); + } +} diff --git a/packages/website/ts/components/generate_order/generate_order_form.tsx b/packages/website/ts/components/generate_order/generate_order_form.tsx new file mode 100644 index 000000000..e9026d9bc --- /dev/null +++ b/packages/website/ts/components/generate_order/generate_order_form.tsx @@ -0,0 +1,348 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {ZeroEx, Order} from '0x.js'; +import BigNumber from 'bignumber.js'; +import {Blockchain} from 'ts/blockchain'; +import Divider from 'material-ui/Divider'; +import Dialog from 'material-ui/Dialog'; +import {colors} from 'material-ui/styles'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {utils} from 'ts/utils/utils'; +import {SchemaValidator} from 'ts/schemas/validator'; +import {orderSchema} from 'ts/schemas/order_schema'; +import {Alert} from 'ts/components/ui/alert'; +import {OrderJSON} from 'ts/components/order_json'; +import {IdenticonAddressInput} from 'ts/components/inputs/identicon_address_input'; +import {TokenInput} from 'ts/components/inputs/token_input'; +import {TokenAmountInput} from 'ts/components/inputs/token_amount_input'; +import {HashInput} from 'ts/components/inputs/hash_input'; +import {ExpirationInput} from 'ts/components/inputs/expiration_input'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {HelpTooltip} from 'ts/components/ui/help_tooltip'; +import {SwapIcon} from 'ts/components/ui/swap_icon'; +import { + Side, + SideToAssetToken, + SignatureData, + HashData, + TokenByAddress, + TokenStateByAddress, + BlockchainErrs, + Token, + AlertTypes, +} from 'ts/types'; + +enum SigningState { + UNSIGNED, + SIGNING, + SIGNED, +} + +interface GenerateOrderFormProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + dispatcher: Dispatcher; + hashData: HashData; + orderExpiryTimestamp: BigNumber; + networkId: number; + userAddress: string; + orderSignatureData: SignatureData; + orderTakerAddress: string; + orderSalt: BigNumber; + sideToAssetToken: SideToAssetToken; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; +} + +interface GenerateOrderFormState { + globalErrMsg: string; + shouldShowIncompleteErrs: boolean; + signingState: SigningState; +} + +const style = { + paper: { + display: 'inline-block', + position: 'relative', + textAlign: 'center', + width: '100%', + }, +}; + +export class GenerateOrderForm extends React.Component { + private validator: SchemaValidator; + constructor(props: GenerateOrderFormProps) { + super(props); + this.state = { + globalErrMsg: '', + shouldShowIncompleteErrs: false, + signingState: SigningState.UNSIGNED, + }; + this.validator = new SchemaValidator(); + } + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + const dispatcher = this.props.dispatcher; + const depositTokenAddress = this.props.sideToAssetToken[Side.deposit].address; + const depositToken = this.props.tokenByAddress[depositTokenAddress]; + const depositTokenState = this.props.tokenStateByAddress[depositTokenAddress]; + const receiveTokenAddress = this.props.sideToAssetToken[Side.receive].address; + const receiveToken = this.props.tokenByAddress[receiveTokenAddress]; + const receiveTokenState = this.props.tokenStateByAddress[receiveTokenAddress]; + const takerExplanation = 'If a taker is specified, only they are
\ + allowed to fill this order. If no taker is
\ + specified, anyone is able to fill it.'; + const exchangeContractIfExists = this.props.blockchain.getExchangeContractAddressIfExists(); + return ( +
+

Generate an order

+ +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+
+
Expiration
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+ {this.state.globalErrMsg !== '' && + + } +
+
+ + + +
+ ); + } + private onTokenAmountChange(token: Token, side: Side, isValid: boolean, amount?: BigNumber) { + this.props.dispatcher.updateChosenAssetToken(side, {address: token.address, amount}); + } + private onCloseOrderJSONDialog() { + // Upon closing the order JSON dialog, we update the orderSalt stored in the Redux store + // with a new value so that if a user signs the identical order again, the newly signed + // orderHash will not collide with the previously generated orderHash. + this.props.dispatcher.updateOrderSalt(ZeroEx.generatePseudoRandomSalt()); + this.setState({ + signingState: SigningState.UNSIGNED, + }); + } + private async onSignClickedAsync(): Promise { + if (this.props.blockchainErr !== '') { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return false; + } + + // Check if all required inputs were supplied + const debitToken = this.props.sideToAssetToken[Side.deposit]; + const debitBalance = this.props.tokenStateByAddress[debitToken.address].balance; + const debitAllowance = this.props.tokenStateByAddress[debitToken.address].allowance; + const receiveAmount = this.props.sideToAssetToken[Side.receive].amount; + if (!_.isUndefined(debitToken.amount) && !_.isUndefined(receiveAmount) && + debitToken.amount.gt(0) && receiveAmount.gt(0) && + this.props.userAddress !== '' && + debitBalance.gte(debitToken.amount) && debitAllowance.gte(debitToken.amount)) { + const didSignSuccessfully = await this.signTransactionAsync(); + if (didSignSuccessfully) { + this.setState({ + globalErrMsg: '', + shouldShowIncompleteErrs: false, + }); + } + return didSignSuccessfully; + } else { + let globalErrMsg = 'You must fix the above errors in order to generate a valid order'; + if (this.props.userAddress === '') { + globalErrMsg = 'You must enable wallet communication'; + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + } + this.setState({ + globalErrMsg, + shouldShowIncompleteErrs: true, + }); + return false; + } + } + private async signTransactionAsync(): Promise { + this.setState({ + signingState: SigningState.SIGNING, + }); + const exchangeContractAddr = this.props.blockchain.getExchangeContractAddressIfExists(); + if (_.isUndefined(exchangeContractAddr)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + this.setState({ + isSigning: false, + }); + return false; + } + const hashData = this.props.hashData; + + const zeroExOrder: Order = { + exchangeContractAddress: exchangeContractAddr, + expirationUnixTimestampSec: hashData.orderExpiryTimestamp, + feeRecipient: hashData.feeRecipientAddress, + maker: hashData.orderMakerAddress, + makerFee: hashData.makerFee, + makerTokenAddress: hashData.depositTokenContractAddr, + makerTokenAmount: hashData.depositAmount, + salt: hashData.orderSalt, + taker: hashData.orderTakerAddress, + takerFee: hashData.takerFee, + takerTokenAddress: hashData.receiveTokenContractAddr, + takerTokenAmount: hashData.receiveAmount, + }; + const orderHash = ZeroEx.getOrderHashHex(zeroExOrder); + + let globalErrMsg = ''; + try { + const signatureData = await this.props.blockchain.signOrderHashAsync(orderHash); + const order = utils.generateOrder(this.props.networkId, exchangeContractAddr, this.props.sideToAssetToken, + hashData.orderExpiryTimestamp, this.props.orderTakerAddress, + this.props.userAddress, hashData.makerFee, hashData.takerFee, + hashData.feeRecipientAddress, signatureData, this.props.tokenByAddress, + hashData.orderSalt); + const validationResult = this.validator.validate(order, orderSchema); + if (validationResult.errors.length > 0) { + globalErrMsg = 'Order signing failed. Please refresh and try again'; + utils.consoleLog(`Unexpected error occured: Order validation failed: + ${validationResult.errors}`); + } + } catch (err) { + const errMsg = '' + err; + if (utils.didUserDenyWeb3Request(errMsg)) { + globalErrMsg = 'User denied sign request'; + } else { + globalErrMsg = 'An unexpected error occured. Please try refreshing the page'; + utils.consoleLog(`Unexpected error occured: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + } + } + this.setState({ + signingState: globalErrMsg === '' ? SigningState.SIGNED : SigningState.UNSIGNED, + globalErrMsg, + }); + return globalErrMsg === ''; + } + private updateOrderAddress(address?: string): void { + if (!_.isUndefined(address)) { + this.props.dispatcher.updateOrderTakerAddress(address); + } + } +} diff --git a/packages/website/ts/components/generate_order/new_token_form.tsx b/packages/website/ts/components/generate_order/new_token_form.tsx new file mode 100644 index 000000000..95c05f5bb --- /dev/null +++ b/packages/website/ts/components/generate_order/new_token_form.tsx @@ -0,0 +1,237 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import TextField from 'material-ui/TextField'; +import {constants} from 'ts/utils/constants'; +import {Blockchain} from 'ts/blockchain'; +import {Token, TokenState, TokenByAddress, AlertTypes} from 'ts/types'; +import {AddressInput} from 'ts/components/inputs/address_input'; +import {Alert} from 'ts/components/ui/alert'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; +import {RequiredLabel} from 'ts/components/ui/required_label'; +import BigNumber from 'bignumber.js'; + +interface NewTokenFormProps { + blockchain: Blockchain; + tokenByAddress: TokenByAddress; + onNewTokenSubmitted: (token: Token, tokenState: TokenState) => void; +} + +interface NewTokenFormState { + globalErrMsg: string; + name: string; + nameErrText: string; + symbol: string; + symbolErrText: string; + address: string; + shouldShowAddressIncompleteErr: boolean; + decimals: string; + decimalsErrText: string; +} + +export class NewTokenForm extends React.Component { + constructor(props: NewTokenFormProps) { + super(props); + this.state = { + address: '', + globalErrMsg: '', + name: '', + nameErrText: '', + shouldShowAddressIncompleteErr: false, + symbol: '', + symbolErrText: '', + decimals: '18', + decimalsErrText: '', + }; + } + public render() { + return ( +
+
+ } + value={this.state.name} + errorText={this.state.nameErrText} + onChange={this.onTokenNameChanged.bind(this)} + /> +
+
+ } + value={this.state.symbol} + errorText={this.state.symbolErrText} + onChange={this.onTokenSymbolChanged.bind(this)} + /> +
+
+ +
+
+ } + value={this.state.decimals} + errorText={this.state.decimalsErrText} + onChange={this.onTokenDecimalsChanged.bind(this)} + /> +
+
+ +
+ {this.state.globalErrMsg !== '' && + + } +
+ ); + } + private async onAddNewTokenClickAsync() { + // Trigger validation of name and symbol + this.onTokenNameChanged(undefined, this.state.name); + this.onTokenSymbolChanged(undefined, this.state.symbol); + this.onTokenDecimalsChanged(undefined, this.state.decimals); + + const isAddressIncomplete = this.state.address === ''; + let doesContractExist = false; + if (!isAddressIncomplete) { + doesContractExist = await this.props.blockchain.doesContractExistAtAddressAsync(this.state.address); + } + + let hasBalanceAllowanceErr = false; + let balance = new BigNumber(0); + let allowance = new BigNumber(0); + if (doesContractExist) { + try { + [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(this.state.address); + } catch (err) { + hasBalanceAllowanceErr = true; + } + } + + let globalErrMsg = ''; + if (this.state.nameErrText !== '' || this.state.symbolErrText !== '' || + this.state.decimalsErrText !== '' || isAddressIncomplete) { + globalErrMsg = 'Please fix the above issues'; + } else if (!doesContractExist) { + globalErrMsg = 'No contract found at supplied address'; + } else if (hasBalanceAllowanceErr) { + globalErrMsg = 'Unsuccessful call to `balanceOf` and/or `allowance` on supplied contract address'; + } else if (!isAddressIncomplete && !_.isUndefined(this.props.tokenByAddress[this.state.address])) { + globalErrMsg = 'A token already exists with this address'; + } + + if (globalErrMsg !== '') { + this.setState({ + globalErrMsg, + shouldShowAddressIncompleteErr: isAddressIncomplete, + }); + return; + } + + const newToken: Token = { + address: this.state.address, + decimals: _.parseInt(this.state.decimals), + iconUrl: undefined, + name: this.state.name, + symbol: this.state.symbol.toUpperCase(), + isTracked: true, + isRegistered: false, + }; + const newTokenState: TokenState = { + balance, + allowance, + }; + this.props.onNewTokenSubmitted(newToken, newTokenState); + } + private onTokenNameChanged(e: any, name: string) { + let nameErrText = ''; + const maxLength = 30; + const tokens = _.values(this.props.tokenByAddress); + const tokenWithNameIfExists = _.find(tokens, {name}); + const tokenWithNameExists = !_.isUndefined(tokenWithNameIfExists); + if (name === '') { + nameErrText = 'Name is required'; + } else if (!this.isValidName(name)) { + nameErrText = 'Name should only contain letters, digits and spaces'; + } else if (name.length > maxLength) { + nameErrText = `Max length is ${maxLength}`; + } else if (tokenWithNameExists) { + nameErrText = 'Token with this name already exists'; + } + + this.setState({ + name, + nameErrText, + }); + } + private onTokenSymbolChanged(e: any, symbol: string) { + let symbolErrText = ''; + const maxLength = 5; + const tokens = _.values(this.props.tokenByAddress); + const tokenWithSymbolExists = !_.isUndefined(_.find(tokens, {symbol})); + if (symbol === '') { + symbolErrText = 'Symbol is required'; + } else if (!this.isLetters(symbol)) { + symbolErrText = 'Can only include letters'; + } else if (symbol.length > maxLength) { + symbolErrText = `Max length is ${maxLength}`; + } else if (tokenWithSymbolExists) { + symbolErrText = 'Token with symbol already exists'; + } + + this.setState({ + symbol, + symbolErrText, + }); + } + private onTokenDecimalsChanged(e: any, decimals: string) { + let decimalsErrText = ''; + const maxLength = 2; + if (decimals === '') { + decimalsErrText = 'Decimals is required'; + } else if (!this.isInteger(decimals)) { + decimalsErrText = 'Must be an integer'; + } else if (decimals.length > maxLength) { + decimalsErrText = `Max length is ${maxLength}`; + } + + this.setState({ + decimals, + decimalsErrText, + }); + } + private onTokenAddressChanged(address?: string) { + if (!_.isUndefined(address)) { + this.setState({ + address, + }); + } + } + private isValidName(input: string) { + return /^[a-z0-9 ]+$/i.test(input); + } + private isInteger(input: string) { + return /^[0-9]+$/i.test(input); + } + private isLetters(input: string) { + return /^[a-zA-Z]+$/i.test(input); + } +} diff --git a/packages/website/ts/components/inputs/address_input.tsx b/packages/website/ts/components/inputs/address_input.tsx new file mode 100644 index 000000000..57ad7a5e2 --- /dev/null +++ b/packages/website/ts/components/inputs/address_input.tsx @@ -0,0 +1,74 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {isAddress} from 'ethereum-address'; +import TextField from 'material-ui/TextField'; +import {colors} from 'material-ui/styles'; +import {Blockchain} from 'ts/blockchain'; +import {RequiredLabel} from 'ts/components/ui/required_label'; + +interface AddressInputProps { + disabled?: boolean; + initialAddress: string; + isRequired?: boolean; + hintText?: string; + shouldHideLabel?: boolean; + label?: string; + shouldShowIncompleteErrs?: boolean; + updateAddress: (address?: string) => void; +} + +interface AddressInputState { + address: string; + errMsg: string; +} + +export class AddressInput extends React.Component { + constructor(props: AddressInputProps) { + super(props); + this.state = { + address: this.props.initialAddress, + errMsg: '', + }; + } + public componentWillReceiveProps(nextProps: AddressInputProps) { + if (nextProps.shouldShowIncompleteErrs && this.props.isRequired && + this.state.address === '') { + this.setState({ + errMsg: 'Address is required', + }); + } + } + public render() { + const label = this.props.isRequired ? : + this.props.label; + const labelDisplay = this.props.shouldHideLabel ? 'hidden' : 'block'; + const hintText = this.props.hintText ? this.props.hintText : ''; + return ( +
+ +
+ ); + } + private onOrderTakerAddressUpdated(e: any) { + const address = e.target.value.toLowerCase(); + const isValidAddress = isAddress(address) || address === ''; + const errMsg = isValidAddress ? '' : 'Invalid ethereum address'; + this.setState({ + address, + errMsg, + }); + const addressIfValid = isValidAddress ? address : undefined; + this.props.updateAddress(addressIfValid); + } +} diff --git a/packages/website/ts/components/inputs/allowance_toggle.tsx b/packages/website/ts/components/inputs/allowance_toggle.tsx new file mode 100644 index 000000000..f02112253 --- /dev/null +++ b/packages/website/ts/components/inputs/allowance_toggle.tsx @@ -0,0 +1,94 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import Toggle from 'material-ui/Toggle'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {Token, TokenState, BalanceErrs} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {errorReporter} from 'ts/utils/error_reporter'; + +const DEFAULT_ALLOWANCE_AMOUNT_IN_BASE_UNITS = new BigNumber(2).pow(256).minus(1); + +interface AllowanceToggleProps { + blockchain: Blockchain; + dispatcher: Dispatcher; + onErrorOccurred: (errType: BalanceErrs) => void; + token: Token; + tokenState: TokenState; + userAddress: string; +} + +interface AllowanceToggleState { + isSpinnerVisible: boolean; + prevAllowance: BigNumber; +} + +export class AllowanceToggle extends React.Component { + constructor(props: AllowanceToggleProps) { + super(props); + this.state = { + isSpinnerVisible: false, + prevAllowance: props.tokenState.allowance, + }; + } + public componentWillReceiveProps(nextProps: AllowanceToggleProps) { + if (!nextProps.tokenState.allowance.eq(this.state.prevAllowance)) { + this.setState({ + isSpinnerVisible: false, + prevAllowance: nextProps.tokenState.allowance, + }); + } + } + public render() { + return ( +
+
+ +
+ {this.state.isSpinnerVisible && +
+ +
+ } +
+ ); + } + private async onToggleAllowanceAsync() { + if (this.props.userAddress === '') { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return false; + } + + this.setState({ + isSpinnerVisible: true, + }); + + let newAllowanceAmountInBaseUnits = new BigNumber(0); + if (!this.isAllowanceSet()) { + newAllowanceAmountInBaseUnits = DEFAULT_ALLOWANCE_AMOUNT_IN_BASE_UNITS; + } + try { + await this.props.blockchain.setProxyAllowanceAsync(this.props.token, newAllowanceAmountInBaseUnits); + } catch (err) { + this.setState({ + isSpinnerVisible: false, + }); + const errMsg = '' + err; + if (_.includes(errMsg, 'User denied transaction')) { + return false; + } + utils.consoleLog(`Unexpected error encountered: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + this.props.onErrorOccurred(BalanceErrs.allowanceSettingFailed); + } + } + private isAllowanceSet() { + return !this.props.tokenState.allowance.eq(0); + } +} diff --git a/packages/website/ts/components/inputs/balance_bounded_input.tsx b/packages/website/ts/components/inputs/balance_bounded_input.tsx new file mode 100644 index 000000000..1c8b410a4 --- /dev/null +++ b/packages/website/ts/components/inputs/balance_bounded_input.tsx @@ -0,0 +1,160 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import {ValidatedBigNumberCallback, InputErrMsg, WebsitePaths} from 'ts/types'; +import TextField from 'material-ui/TextField'; +import {RequiredLabel} from 'ts/components/ui/required_label'; +import {colors} from 'material-ui/styles'; +import {utils} from 'ts/utils/utils'; +import {Link} from 'react-router-dom'; + +interface BalanceBoundedInputProps { + label?: string; + balance: BigNumber; + amount?: BigNumber; + onChange: ValidatedBigNumberCallback; + shouldShowIncompleteErrs?: boolean; + shouldCheckBalance: boolean; + validate?: (amount: BigNumber) => InputErrMsg; + onVisitBalancesPageClick?: () => void; + shouldHideVisitBalancesLink?: boolean; +} + +interface BalanceBoundedInputState { + errMsg: InputErrMsg; + amountString: string; +} + +export class BalanceBoundedInput extends + React.Component { + public static defaultProps: Partial = { + shouldShowIncompleteErrs: false, + shouldHideVisitBalancesLink: false, + }; + constructor(props: BalanceBoundedInputProps) { + super(props); + const amountString = this.props.amount ? this.props.amount.toString() : ''; + this.state = { + errMsg: this.validate(amountString, props.balance), + amountString, + }; + } + public componentWillReceiveProps(nextProps: BalanceBoundedInputProps) { + if (nextProps === this.props) { + return; + } + const isCurrentAmountNumeric = utils.isNumeric(this.state.amountString); + if (!_.isUndefined(nextProps.amount)) { + let shouldResetState = false; + if (!isCurrentAmountNumeric) { + shouldResetState = true; + } else { + const currentAmount = new BigNumber(this.state.amountString); + if (!currentAmount.eq(nextProps.amount) || !nextProps.balance.eq(this.props.balance)) { + shouldResetState = true; + } + } + if (shouldResetState) { + const amountString = nextProps.amount.toString(); + this.setState({ + errMsg: this.validate(amountString, nextProps.balance), + amountString, + }); + } + } else if (isCurrentAmountNumeric) { + const amountString = ''; + this.setState({ + errMsg: this.validate(amountString, nextProps.balance), + amountString, + }); + } + } + public render() { + let errorText = this.state.errMsg; + if (this.props.shouldShowIncompleteErrs && this.state.amountString === '') { + errorText = 'This field is required'; + } + let label: React.ReactNode|string = ''; + if (!_.isUndefined(this.props.label)) { + label = ; + } + return ( + amount} + onChange={this.onValueChange.bind(this)} + underlineStyle={{width: 'calc(100% + 50px)'}} + /> + ); + } + private onValueChange(e: any, amountString: string) { + const errMsg = this.validate(amountString, this.props.balance); + this.setState({ + amountString, + errMsg, + }, () => { + const isValid = _.isUndefined(errMsg); + if (utils.isNumeric(amountString)) { + this.props.onChange(isValid, new BigNumber(amountString)); + } else { + this.props.onChange(isValid); + } + }); + } + private validate(amountString: string, balance: BigNumber): InputErrMsg { + if (!utils.isNumeric(amountString)) { + return amountString !== '' ? 'Must be a number' : ''; + } + const amount = new BigNumber(amountString); + if (amount.eq(0)) { + return 'Cannot be zero'; + } + if (this.props.shouldCheckBalance && amount.gt(balance)) { + return ( + + Insufficient balance.{' '} + {this.renderIncreaseBalanceLink()} + + ); + } + const errMsg = _.isUndefined(this.props.validate) ? undefined : this.props.validate(amount); + return errMsg; + } + private renderIncreaseBalanceLink() { + if (this.props.shouldHideVisitBalancesLink) { + return null; + } + + const increaseBalanceText = 'Increase balance'; + const linkStyle = { + cursor: 'pointer', + color: colors.grey900, + textDecoration: 'underline', + display: 'inline', + }; + if (_.isUndefined(this.props.onVisitBalancesPageClick)) { + return ( + + {increaseBalanceText} + + ); + } else { + return ( +
+ {increaseBalanceText} +
+ ); + } + } +} diff --git a/packages/website/ts/components/inputs/eth_amount_input.tsx b/packages/website/ts/components/inputs/eth_amount_input.tsx new file mode 100644 index 000000000..ad551e125 --- /dev/null +++ b/packages/website/ts/components/inputs/eth_amount_input.tsx @@ -0,0 +1,51 @@ +import BigNumber from 'bignumber.js'; +import * as _ from 'lodash'; +import * as React from 'react'; +import {ZeroEx} from '0x.js'; +import {ValidatedBigNumberCallback} from 'ts/types'; +import {BalanceBoundedInput} from 'ts/components/inputs/balance_bounded_input'; +import {constants} from 'ts/utils/constants'; + +interface EthAmountInputProps { + label?: string; + balance: BigNumber; + amount?: BigNumber; + onChange: ValidatedBigNumberCallback; + shouldShowIncompleteErrs: boolean; + onVisitBalancesPageClick?: () => void; + shouldCheckBalance: boolean; + shouldHideVisitBalancesLink?: boolean; +} + +interface EthAmountInputState {} + +export class EthAmountInput extends React.Component { + public render() { + const amount = this.props.amount ? + ZeroEx.toUnitAmount(this.props.amount, constants.ETH_DECIMAL_PLACES) : + undefined; + return ( +
+ +
+ ETH +
+
+ ); + } + private onChange(isValid: boolean, amount?: BigNumber) { + const baseUnitAmountIfExists = _.isUndefined(amount) ? + undefined : + ZeroEx.toBaseUnitAmount(amount, constants.ETH_DECIMAL_PLACES); + this.props.onChange(isValid, baseUnitAmountIfExists); + } +} diff --git a/packages/website/ts/components/inputs/expiration_input.tsx b/packages/website/ts/components/inputs/expiration_input.tsx new file mode 100644 index 000000000..32dcad189 --- /dev/null +++ b/packages/website/ts/components/inputs/expiration_input.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import DatePicker from 'material-ui/DatePicker'; +import TimePicker from 'material-ui/TimePicker'; +import {utils} from 'ts/utils/utils'; +import BigNumber from 'bignumber.js'; +import * as moment from 'moment'; + +interface ExpirationInputProps { + orderExpiryTimestamp: BigNumber; + updateOrderExpiry: (unixTimestampSec: BigNumber) => void; +} + +interface ExpirationInputState { + dateMoment: moment.Moment; + timeMoment: moment.Moment; +} + +export class ExpirationInput extends React.Component { + private earliestPickableMoment: moment.Moment; + constructor(props: ExpirationInputProps) { + super(props); + // Set the earliest pickable date to today at 00:00, so users can only pick the current or later dates + this.earliestPickableMoment = moment().startOf('day'); + const expirationMoment = utils.convertToMomentFromUnixTimestamp(props.orderExpiryTimestamp); + const initialOrderExpiryTimestamp = utils.initialOrderExpiryUnixTimestampSec(); + const didUserSetExpiry = !initialOrderExpiryTimestamp.eq(props.orderExpiryTimestamp); + this.state = { + dateMoment: didUserSetExpiry ? expirationMoment : undefined, + timeMoment: didUserSetExpiry ? expirationMoment : undefined, + }; + } + public render() { + const date = this.state.dateMoment ? this.state.dateMoment.toDate() : undefined; + const time = this.state.timeMoment ? this.state.timeMoment.toDate() : undefined; + return ( +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+ ); + } + private shouldDisableDate(date: Date): boolean { + return moment(date).startOf('day').isBefore(this.earliestPickableMoment); + } + private clearDates() { + this.setState({ + dateMoment: undefined, + timeMoment: undefined, + }); + const defaultDateTime = utils.initialOrderExpiryUnixTimestampSec(); + this.props.updateOrderExpiry(defaultDateTime); + } + private onDateChanged(e: any, date: Date) { + const dateMoment = moment(date); + this.setState({ + dateMoment, + }); + const timestamp = utils.convertToUnixTimestampSeconds(dateMoment, this.state.timeMoment); + this.props.updateOrderExpiry(timestamp); + } + private onTimeChanged(e: any, time: Date) { + const timeMoment = moment(time); + this.setState({ + timeMoment, + }); + const dateMoment = _.isUndefined(this.state.dateMoment) ? moment() : this.state.dateMoment; + const timestamp = utils.convertToUnixTimestampSeconds(dateMoment, timeMoment); + this.props.updateOrderExpiry(timestamp); + } +} diff --git a/packages/website/ts/components/inputs/hash_input.tsx b/packages/website/ts/components/inputs/hash_input.tsx new file mode 100644 index 000000000..3e42f1d5f --- /dev/null +++ b/packages/website/ts/components/inputs/hash_input.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import {Blockchain} from 'ts/blockchain'; +import {ZeroEx, Order} from '0x.js'; +import {FakeTextField} from 'ts/components/ui/fake_text_field'; +import ReactTooltip = require('react-tooltip'); +import {HashData, Styles} from 'ts/types'; +import {constants} from 'ts/utils/constants'; + +const styles: Styles = { + textField: { + overflow: 'hidden', + paddingTop: 8, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, +}; + +interface HashInputProps { + blockchain: Blockchain; + blockchainIsLoaded: boolean; + hashData: HashData; + label: string; +} + +interface HashInputState {} + +export class HashInput extends React.Component { + public render() { + const msgHashHex = this.props.blockchainIsLoaded ? this.generateMessageHashHex() : ''; + return ( +
+ +
+ {msgHashHex} +
+
+ {msgHashHex} +
+ ); + } + private generateMessageHashHex() { + const exchangeContractAddress = this.props.blockchain.getExchangeContractAddressIfExists(); + const hashData = this.props.hashData; + const order: Order = { + exchangeContractAddress, + expirationUnixTimestampSec: hashData.orderExpiryTimestamp, + feeRecipient: hashData.feeRecipientAddress, + maker: hashData.orderMakerAddress, + makerFee: hashData.makerFee, + makerTokenAddress: hashData.depositTokenContractAddr, + makerTokenAmount: hashData.depositAmount, + salt: hashData.orderSalt, + taker: hashData.orderTakerAddress, + takerFee: hashData.takerFee, + takerTokenAddress: hashData.receiveTokenContractAddr, + takerTokenAmount: hashData.receiveAmount, + }; + const orderHash = ZeroEx.getOrderHashHex(order); + return orderHash; + } +} diff --git a/packages/website/ts/components/inputs/identicon_address_input.tsx b/packages/website/ts/components/inputs/identicon_address_input.tsx new file mode 100644 index 000000000..6452f5fe9 --- /dev/null +++ b/packages/website/ts/components/inputs/identicon_address_input.tsx @@ -0,0 +1,56 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {Blockchain} from 'ts/blockchain'; +import {Identicon} from 'ts/components/ui/identicon'; +import {RequiredLabel} from 'ts/components/ui/required_label'; +import {AddressInput} from 'ts/components/inputs/address_input'; +import {InputLabel} from 'ts/components/ui/input_label'; + +interface IdenticonAddressInputProps { + initialAddress: string; + isRequired?: boolean; + label: string; + updateOrderAddress: (address?: string) => void; +} + +interface IdenticonAddressInputState { + address: string; +} + +export class IdenticonAddressInput extends React.Component { + constructor(props: IdenticonAddressInputProps) { + super(props); + this.state = { + address: props.initialAddress, + }; + } + public render() { + const label = this.props.isRequired ? : + this.props.label; + return ( +
+ +
+
+ +
+
+ +
+
+
+ ); + } + private updateAddress(address?: string): void { + this.setState({ + address, + }); + this.props.updateOrderAddress(address); + } +} diff --git a/packages/website/ts/components/inputs/token_amount_input.tsx b/packages/website/ts/components/inputs/token_amount_input.tsx new file mode 100644 index 000000000..e19af8984 --- /dev/null +++ b/packages/website/ts/components/inputs/token_amount_input.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import BigNumber from 'bignumber.js'; +import {ZeroEx} from '0x.js'; +import {Link} from 'react-router-dom'; +import {colors} from 'material-ui/styles'; +import {Token, TokenState, InputErrMsg, ValidatedBigNumberCallback, WebsitePaths} from 'ts/types'; +import {BalanceBoundedInput} from 'ts/components/inputs/balance_bounded_input'; + +interface TokenAmountInputProps { + label: string; + token: Token; + tokenState: TokenState; + amount?: BigNumber; + shouldShowIncompleteErrs: boolean; + shouldCheckBalance: boolean; + shouldCheckAllowance: boolean; + onChange: ValidatedBigNumberCallback; + onVisitBalancesPageClick?: () => void; +} + +interface TokenAmountInputState {} + +export class TokenAmountInput extends React.Component { + public render() { + const amount = this.props.amount ? + ZeroEx.toUnitAmount(this.props.amount, this.props.token.decimals) : + undefined; + return ( +
+ +
+ {this.props.token.symbol} +
+
+ ); + } + private onChange(isValid: boolean, amount?: BigNumber) { + let baseUnitAmount; + if (!_.isUndefined(amount)) { + baseUnitAmount = ZeroEx.toBaseUnitAmount(amount, this.props.token.decimals); + } + this.props.onChange(isValid, baseUnitAmount); + } + private validate(amount: BigNumber): InputErrMsg { + if (this.props.shouldCheckAllowance && amount.gt(this.props.tokenState.allowance)) { + return ( + + Insufficient allowance.{' '} + + Set allowance + + + ); + } + } +} diff --git a/packages/website/ts/components/inputs/token_input.tsx b/packages/website/ts/components/inputs/token_input.tsx new file mode 100644 index 000000000..2be74d4fd --- /dev/null +++ b/packages/website/ts/components/inputs/token_input.tsx @@ -0,0 +1,107 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import Paper from 'material-ui/Paper'; +import {colors} from 'material-ui/styles'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {AssetToken, Side, TokenByAddress, BlockchainErrs, Token, TokenState} from 'ts/types'; +import {AssetPicker} from 'ts/components/generate_order/asset_picker'; +import {InputLabel} from 'ts/components/ui/input_label'; +import {TokenIcon} from 'ts/components/ui/token_icon'; + +const TOKEN_ICON_DIMENSION = 80; + +interface TokenInputProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + dispatcher: Dispatcher; + label: string; + side: Side; + networkId: number; + assetToken: AssetToken; + updateChosenAssetToken: (side: Side, token: AssetToken) => void; + tokenByAddress: TokenByAddress; + userAddress: string; +} + +interface TokenInputState { + isHoveringIcon: boolean; + isPickerOpen: boolean; + trackCandidateTokenIfExists?: Token; +} + +export class TokenInput extends React.Component { + constructor(props: TokenInputProps) { + super(props); + this.state = { + isHoveringIcon: false, + isPickerOpen: false, + }; + } + public render() { + const token = this.props.tokenByAddress[this.props.assetToken.address]; + const iconStyles = { + cursor: 'pointer', + opacity: this.state.isHoveringIcon ? 0.5 : 1, + }; + return ( +
+
+ +
+ +
+ +
+
+ {token.name} +
+
+ +
+ ); + } + private onTokenChosen(tokenAddress: string) { + const assetToken: AssetToken = { + address: tokenAddress, + amount: this.props.assetToken.amount, + }; + this.props.updateChosenAssetToken(this.props.side, assetToken); + this.setState({ + isPickerOpen: false, + }); + } + private onToggleHover(isHoveringIcon: boolean) { + this.setState({ + isHoveringIcon, + }); + } + private onAssetClicked() { + if (this.props.blockchainErr !== '') { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return; + } + + this.setState({ + isPickerOpen: true, + }); + } +} diff --git a/packages/website/ts/components/order_json.tsx b/packages/website/ts/components/order_json.tsx new file mode 100644 index 000000000..90e3543dd --- /dev/null +++ b/packages/website/ts/components/order_json.tsx @@ -0,0 +1,164 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import {utils} from 'ts/utils/utils'; +import {colors} from 'material-ui/styles'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import TextField from 'material-ui/TextField'; +import Paper from 'material-ui/Paper'; +import {CopyIcon} from 'ts/components/ui/copy_icon'; +import {SideToAssetToken, SignatureData, Order, TokenByAddress, WebsitePaths} from 'ts/types'; +import {errorReporter} from 'ts/utils/error_reporter'; + +interface OrderJSONProps { + exchangeContractIfExists: string; + orderExpiryTimestamp: BigNumber; + orderSignatureData: SignatureData; + orderTakerAddress: string; + orderMakerAddress: string; + orderSalt: BigNumber; + orderMakerFee: BigNumber; + orderTakerFee: BigNumber; + orderFeeRecipient: string; + networkId: number; + sideToAssetToken: SideToAssetToken; + tokenByAddress: TokenByAddress; +} + +interface OrderJSONState { + shareLink: string; +} + +export class OrderJSON extends React.Component { + constructor(props: OrderJSONProps) { + super(props); + this.state = { + shareLink: '', + }; + this.setShareLinkAsync(); + } + public render() { + const order = utils.generateOrder(this.props.networkId, this.props.exchangeContractIfExists, + this.props.sideToAssetToken, this.props.orderExpiryTimestamp, + this.props.orderTakerAddress, this.props.orderMakerAddress, + this.props.orderMakerFee, this.props.orderTakerFee, + this.props.orderFeeRecipient, this.props.orderSignatureData, + this.props.tokenByAddress, this.props.orderSalt); + const orderJSON = JSON.stringify(order); + return ( +
+
+ You have successfully generated and cryptographically signed an order! The{' '} + following JSON contains the order parameters and cryptographic signature that{' '} + your counterparty will need to execute a trade with you. +
+
+
+ +
+
+ + + +
+
+ Share your signed order! +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ ); + } + private async shareViaTwitterAsync() { + const tweetText = encodeURIComponent(`Fill my order using the 0x protocol: ${this.state.shareLink}`); + window.open(`https://twitter.com/intent/tweet?text=${tweetText}`, 'Share your order', 'width=500,height=400'); + } + private async shareViaFacebook() { + (window as any).FB.ui({ + display: 'popup', + href: this.state.shareLink, + method: 'share', + }, _.noop); + } + private async shareViaEmailAsync() { + const encodedSubject = encodeURIComponent('Let\'s trade using the 0x protocol'); + const encodedBody = encodeURIComponent(`I generated an order with the 0x protocol. +You can see and fill it here: ${this.state.shareLink}`); + const mailToLink = `mailto:mail@example.org?subject=${encodedSubject}&body=${encodedBody}`; + window.open(mailToLink, '_blank'); + } + private async setShareLinkAsync() { + const shareLink = await this.generateShareLinkAsync(); + this.setState({ + shareLink, + }); + } + private async generateShareLinkAsync(): Promise { + const longUrl = encodeURIComponent(this.getOrderUrl()); + const bitlyRequestUrl = constants.BITLY_ENDPOINT + '/v3/shorten?' + + 'access_token=' + constants.BITLY_ACCESS_TOKEN + + '&longUrl=' + longUrl; + const response = await fetch(bitlyRequestUrl); + const responseBody = await response.text(); + const bodyObj = JSON.parse(responseBody); + if (response.status !== 200 || bodyObj.status_code !== 200) { + // TODO: Show error message in UI + utils.consoleLog(`Unexpected status code: ${response.status} -> ${responseBody}`); + await errorReporter.reportAsync(new Error(`Bitly returned non-200: ${JSON.stringify(response)}`)); + return ''; + } + return (bodyObj as any).data.url; + } + private getOrderUrl() { + const order = utils.generateOrder(this.props.networkId, this.props.exchangeContractIfExists, + this.props.sideToAssetToken, this.props.orderExpiryTimestamp, this.props.orderTakerAddress, + this.props.orderMakerAddress, this.props.orderMakerFee, this.props.orderTakerFee, + this.props.orderFeeRecipient, this.props.orderSignatureData, this.props.tokenByAddress, + this.props.orderSalt); + const orderJSONString = JSON.stringify(order); + const orderUrl = `${configs.BASE_URL}${WebsitePaths.Portal}/fill?order=${orderJSONString}`; + return orderUrl; + } +} diff --git a/packages/website/ts/components/portal.tsx b/packages/website/ts/components/portal.tsx new file mode 100644 index 000000000..3591a347b --- /dev/null +++ b/packages/website/ts/components/portal.tsx @@ -0,0 +1,344 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as DocumentTitle from 'react-document-title'; +import {Switch, Route} from 'react-router-dom'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {State} from 'ts/redux/reducer'; +import {utils} from 'ts/utils/utils'; +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; +import Paper from 'material-ui/Paper'; +import RaisedButton from 'material-ui/RaisedButton'; +import {colors} from 'material-ui/styles'; +import {GenerateOrderForm} from 'ts/containers/generate_order_form'; +import {TokenBalances} from 'ts/components/token_balances'; +import {PortalDisclaimerDialog} from 'ts/components/dialogs/portal_disclaimer_dialog'; +import {FillOrder} from 'ts/components/fill_order'; +import {Blockchain} from 'ts/blockchain'; +import {SchemaValidator} from 'ts/schemas/validator'; +import {orderSchema} from 'ts/schemas/order_schema'; +import {localStorage} from 'ts/local_storage/local_storage'; +import {TradeHistory} from 'ts/components/trade_history/trade_history'; +import { + HashData, + TokenByAddress, + BlockchainErrs, + Order, + Fill, + Side, + Styles, + ScreenWidths, + Token, + TokenStateByAddress, + WebsitePaths, +} from 'ts/types'; +import {TopBar} from 'ts/components/top_bar'; +import {Footer} from 'ts/components/footer'; +import {Loading} from 'ts/components/ui/loading'; +import {PortalMenu} from 'ts/components/portal_menu'; +import {BlockchainErrDialog} from 'ts/components/dialogs/blockchain_err_dialog'; +import BigNumber from 'bignumber.js'; +import {FlashMessage} from 'ts/components/ui/flash_message'; + +const THROTTLE_TIMEOUT = 100; + +export interface PortalPassedProps {} + +export interface PortalAllProps { + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + dispatcher: Dispatcher; + hashData: HashData; + networkId: number; + nodeVersion: string; + orderFillAmount: BigNumber; + screenWidth: ScreenWidths; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; + userEtherBalance: BigNumber; + userAddress: string; + shouldBlockchainErrDialogBeOpen: boolean; + userSuppliedOrderCache: Order; + location: Location; + flashMessage?: string|React.ReactNode; +} + +interface PortalAllState { + prevNetworkId: number; + prevNodeVersion: string; + prevUserAddress: string; + hasAcceptedDisclaimer: boolean; +} + +const styles: Styles = { + button: { + color: 'white', + }, + headline: { + fontSize: 20, + fontWeight: 400, + marginBottom: 12, + paddingTop: 16, + }, + inkBar: { + background: colors.amber600, + }, + menuItem: { + padding: '0px 16px 0px 48px', + }, + tabItemContainer: { + background: colors.blueGrey500, + borderRadius: '4px 4px 0 0', + }, +}; + +export class Portal extends React.Component { + private blockchain: Blockchain; + private sharedOrderIfExists: Order; + private throttledScreenWidthUpdate: () => void; + constructor(props: PortalAllProps) { + super(props); + this.sharedOrderIfExists = this.getSharedOrderIfExists(); + this.throttledScreenWidthUpdate = _.throttle(this.updateScreenWidth.bind(this), THROTTLE_TIMEOUT); + this.state = { + prevNetworkId: this.props.networkId, + prevNodeVersion: this.props.nodeVersion, + prevUserAddress: this.props.userAddress, + hasAcceptedDisclaimer: false, + }; + } + public componentDidMount() { + window.addEventListener('resize', this.throttledScreenWidthUpdate); + window.scrollTo(0, 0); + } + public componentWillMount() { + this.blockchain = new Blockchain(this.props.dispatcher); + const didAcceptPortalDisclaimer = localStorage.getItemIfExists(constants.ACCEPT_DISCLAIMER_LOCAL_STORAGE_KEY); + const hasAcceptedDisclaimer = !_.isUndefined(didAcceptPortalDisclaimer) && + !_.isEmpty(didAcceptPortalDisclaimer); + this.setState({ + hasAcceptedDisclaimer, + }); + } + public componentWillUnmount() { + this.blockchain.destroy(); + window.removeEventListener('resize', this.throttledScreenWidthUpdate); + // We re-set the entire redux state when the portal is unmounted so that when it is re-rendered + // the initialization process always occurs from the same base state. This helps avoid + // initialization inconsistencies (i.e While the portal was unrendered, the user might have + // become disconnected from their backing Ethereum node, changes user accounts, etc...) + this.props.dispatcher.resetState(); + } + public componentWillReceiveProps(nextProps: PortalAllProps) { + if (nextProps.networkId !== this.state.prevNetworkId) { + this.blockchain.networkIdUpdatedFireAndForgetAsync(nextProps.networkId); + this.setState({ + prevNetworkId: nextProps.networkId, + }); + } + if (nextProps.userAddress !== this.state.prevUserAddress) { + this.blockchain.userAddressUpdatedFireAndForgetAsync(nextProps.userAddress); + if (!_.isEmpty(nextProps.userAddress) && + nextProps.blockchainIsLoaded) { + const tokens = _.values(nextProps.tokenByAddress); + this.updateBalanceAndAllowanceWithLoadingScreenAsync(tokens); + } + this.setState({ + prevUserAddress: nextProps.userAddress, + }); + } + if (nextProps.nodeVersion !== this.state.prevNodeVersion) { + this.blockchain.nodeVersionUpdatedFireAndForgetAsync(nextProps.nodeVersion); + } + } + public render() { + const updateShouldBlockchainErrDialogBeOpen = this.props.dispatcher + .updateShouldBlockchainErrDialogBeOpen.bind(this.props.dispatcher); + const portalStyle: React.CSSProperties = { + minHeight: '100vh', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }; + return ( +
+ + +
+ + {!configs.isMainnetEnabled && this.props.networkId === constants.MAINNET_NETWORK_ID ? +
+
Mainnet unavailable
+
+ +
+
+ 0x portal is currently unavailable on the Ethereum mainnet. +
+ To try it out, switch to the Kovan test network + (networkId: 42). +
+
+ Check back soon! +
+
+
: +
+
+ +
+
+
+ {this.props.blockchainIsLoaded ? + + + + + + : + + } +
+
+
+ } +
+ + + +
+
+
+ ); + } + private renderTradeHistory() { + return ( + + ); + } + private renderTokenBalances() { + return ( + + ); + } + private renderFillOrder(match: any, location: Location, history: History) { + const initialFillOrder = !_.isUndefined(this.props.userSuppliedOrderCache) ? + this.props.userSuppliedOrderCache : + this.sharedOrderIfExists; + return ( + + ); + } + private renderGenerateOrderForm(match: any, location: Location, history: History) { + return ( + + ); + } + private onPortalDisclaimerAccepted() { + localStorage.setItem(constants.ACCEPT_DISCLAIMER_LOCAL_STORAGE_KEY, 'set'); + this.setState({ + hasAcceptedDisclaimer: true, + }); + } + private getSharedOrderIfExists(): Order { + const queryString = window.location.search; + if (queryString.length === 0) { + return; + } + const queryParams = queryString.substring(1).split('&'); + const orderQueryParam = _.find(queryParams, queryParam => { + const queryPair = queryParam.split('='); + return queryPair[0] === 'order'; + }); + if (_.isUndefined(orderQueryParam)) { + return; + } + const orderPair = orderQueryParam.split('='); + if (orderPair.length !== 2) { + return; + } + + const validator = new SchemaValidator(); + const order = JSON.parse(decodeURIComponent(orderPair[1])); + const validationResult = validator.validate(order, orderSchema); + if (validationResult.errors.length > 0) { + utils.consoleLog(`Invalid shared order: ${validationResult.errors}`); + return; + } + return order; + } + private updateScreenWidth() { + const newScreenWidth = utils.getScreenWidth(); + this.props.dispatcher.updateScreenWidth(newScreenWidth); + } + private async updateBalanceAndAllowanceWithLoadingScreenAsync(tokens: Token[]) { + this.props.dispatcher.updateBlockchainIsLoaded(false); + await this.blockchain.updateTokenBalancesAndAllowancesAsync(tokens); + this.props.dispatcher.updateBlockchainIsLoaded(true); + } +} diff --git a/packages/website/ts/components/portal_menu.tsx b/packages/website/ts/components/portal_menu.tsx new file mode 100644 index 000000000..3b3641729 --- /dev/null +++ b/packages/website/ts/components/portal_menu.tsx @@ -0,0 +1,68 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {MenuItem} from 'ts/components/ui/menu_item'; +import {Link} from 'react-router-dom'; +import {WebsitePaths} from 'ts/types'; + +export interface PortalMenuProps { + menuItemStyle: React.CSSProperties; + onClick?: () => void; +} + +interface PortalMenuState {} + +export class PortalMenu extends React.Component { + public static defaultProps: Partial = { + onClick: _.noop, + }; + public render() { + return ( +
+ + {this.renderMenuItemWithIcon('Generate order', 'zmdi-arrow-right-top')} + + + {this.renderMenuItemWithIcon('Fill order', 'zmdi-arrow-left-bottom')} + + + {this.renderMenuItemWithIcon('Balances', 'zmdi-balance-wallet')} + + + {this.renderMenuItemWithIcon('Trade history', 'zmdi-format-list-bulleted')} + +
+ ); + } + private renderMenuItemWithIcon(title: string, iconName: string) { + return ( +
+
+ +
+
+ {title} +
+
+ ); + } +} diff --git a/packages/website/ts/components/send_button.tsx b/packages/website/ts/components/send_button.tsx new file mode 100644 index 000000000..274ba96a7 --- /dev/null +++ b/packages/website/ts/components/send_button.tsx @@ -0,0 +1,89 @@ +import * as _ from 'lodash'; +import {ZeroEx} from '0x.js'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import RaisedButton from 'material-ui/RaisedButton'; +import {BlockchainCallErrs, Token, TokenState} from 'ts/types'; +import {SendDialog} from 'ts/components/dialogs/send_dialog'; +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {Blockchain} from 'ts/blockchain'; + +interface SendButtonProps { + token: Token; + tokenState: TokenState; + dispatcher: Dispatcher; + blockchain: Blockchain; + onError: () => void; +} + +interface SendButtonState { + isSendDialogVisible: boolean; + isSending: boolean; +} + +export class SendButton extends React.Component { + public constructor(props: SendButtonProps) { + super(props); + this.state = { + isSendDialogVisible: false, + isSending: false, + }; + } + public render() { + const labelStyle = this.state.isSending ? {fontSize: 10} : {}; + return ( +
+ + +
+ ); + } + private toggleSendDialog() { + this.setState({ + isSendDialogVisible: !this.state.isSendDialogVisible, + }); + } + private async onSendAmountSelectedAsync(recipient: string, value: BigNumber) { + this.setState({ + isSending: true, + }); + this.toggleSendDialog(); + const token = this.props.token; + const tokenState = this.props.tokenState; + let balance = tokenState.balance; + try { + await this.props.blockchain.transferAsync(token, recipient, value); + balance = balance.minus(value); + this.props.dispatcher.replaceTokenBalanceByAddress(token.address, balance); + } catch (err) { + const errMsg = `${err}`; + if (_.includes(errMsg, BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return; + } else if (!_.includes(errMsg, 'User denied transaction')) { + utils.consoleLog(`Unexpected error encountered: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + this.props.onError(); + } + } + this.setState({ + isSending: false, + }); + } +} diff --git a/packages/website/ts/components/token_balances.tsx b/packages/website/ts/components/token_balances.tsx new file mode 100644 index 000000000..c552d19dc --- /dev/null +++ b/packages/website/ts/components/token_balances.tsx @@ -0,0 +1,697 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {ZeroEx} from '0x.js'; +import DharmaLoanFrame from 'dharma-loan-frame'; +import {colors} from 'material-ui/styles'; +import Dialog from 'material-ui/Dialog'; +import Divider from 'material-ui/Divider'; +import FlatButton from 'material-ui/FlatButton'; +import RaisedButton from 'material-ui/RaisedButton'; +import FloatingActionButton from 'material-ui/FloatingActionButton'; +import ContentAdd from 'material-ui/svg-icons/content/add'; +import ContentRemove from 'material-ui/svg-icons/content/remove'; +import { + Table, + TableBody, + TableHeader, + TableRow, + TableHeaderColumn, + TableRowColumn, +} from 'material-ui/Table'; +import ReactTooltip = require('react-tooltip'); +import BigNumber from 'bignumber.js'; +import firstBy = require('thenby'); +import QueryString = require('query-string'); +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + TokenByAddress, + TokenStateByAddress, + Token, + BlockchainErrs, + BalanceErrs, + Styles, + ScreenWidths, + EtherscanLinkSuffixes, + BlockchainCallErrs, + TokenVisibility, +} from 'ts/types'; +import {Blockchain} from 'ts/blockchain'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; +import {HelpTooltip} from 'ts/components/ui/help_tooltip'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {AllowanceToggle} from 'ts/components/inputs/allowance_toggle'; +import {EthWethConversionButton} from 'ts/components/eth_weth_conversion_button'; +import {SendButton} from 'ts/components/send_button'; +import {AssetPicker} from 'ts/components/generate_order/asset_picker'; +import {TokenIcon} from 'ts/components/ui/token_icon'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; + +const ETHER_ICON_PATH = '/images/ether.png'; +const ETHER_TOKEN_SYMBOL = 'WETH'; +const ZRX_TOKEN_SYMBOL = 'ZRX'; + +const PRECISION = 5; +const ICON_DIMENSION = 40; +const ARTIFICIAL_FAUCET_REQUEST_DELAY = 1000; +const TOKEN_TABLE_ROW_HEIGHT = 60; +const MAX_TOKEN_TABLE_HEIGHT = 420; +const TOKEN_COL_SPAN_LG = 2; +const TOKEN_COL_SPAN_SM = 1; + +const styles: Styles = { + bgColor: { + backgroundColor: colors.grey50, + }, +}; + +interface TokenBalancesProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + dispatcher: Dispatcher; + screenWidth: ScreenWidths; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; + userAddress: string; + userEtherBalance: BigNumber; + networkId: number; +} + +interface TokenBalancesState { + errorType: BalanceErrs; + isBalanceSpinnerVisible: boolean; + isDharmaDialogVisible: boolean; + isZRXSpinnerVisible: boolean; + currentZrxBalance?: BigNumber; + isTokenPickerOpen: boolean; + isAddingToken: boolean; +} + +export class TokenBalances extends React.Component { + public constructor(props: TokenBalancesProps) { + super(props); + this.state = { + errorType: undefined, + isBalanceSpinnerVisible: false, + isZRXSpinnerVisible: false, + isDharmaDialogVisible: DharmaLoanFrame.isAuthTokenPresent(), + isTokenPickerOpen: false, + isAddingToken: false, + }; + } + public componentWillReceiveProps(nextProps: TokenBalancesProps) { + if (nextProps.userEtherBalance !== this.props.userEtherBalance) { + if (this.state.isBalanceSpinnerVisible) { + const receivedAmount = nextProps.userEtherBalance.minus(this.props.userEtherBalance); + this.props.dispatcher.showFlashMessage(`Received ${receivedAmount.toString(10)} Kovan Ether`); + } + this.setState({ + isBalanceSpinnerVisible: false, + }); + } + const nextZrxToken = _.find(_.values(nextProps.tokenByAddress), t => t.symbol === ZRX_TOKEN_SYMBOL); + const nextZrxTokenBalance = nextProps.tokenStateByAddress[nextZrxToken.address].balance; + if (!_.isUndefined(this.state.currentZrxBalance) && !nextZrxTokenBalance.eq(this.state.currentZrxBalance)) { + if (this.state.isZRXSpinnerVisible) { + const receivedAmount = nextZrxTokenBalance.minus(this.state.currentZrxBalance); + const receiveAmountInUnits = ZeroEx.toUnitAmount(receivedAmount, 18); + this.props.dispatcher.showFlashMessage(`Received ${receiveAmountInUnits.toString(10)} Kovan ZRX`); + } + this.setState({ + isZRXSpinnerVisible: false, + currentZrxBalance: undefined, + }); + } + } + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + const errorDialogActions = [ + , + ]; + const dharmaDialogActions = [ + , + ]; + const isTestNetwork = this.props.networkId === constants.TESTNET_NETWORK_ID; + const dharmaButtonColumnStyle = { + paddingLeft: 3, + display: isTestNetwork ? 'table-cell' : 'none', + }; + const stubColumnStyle = { + display: isTestNetwork ? 'none' : 'table-cell', + }; + const allTokenRowHeight = _.size(this.props.tokenByAddress) * TOKEN_TABLE_ROW_HEIGHT; + const tokenTableHeight = allTokenRowHeight < MAX_TOKEN_TABLE_HEIGHT ? + allTokenRowHeight : + MAX_TOKEN_TABLE_HEIGHT; + const isSmallScreen = this.props.screenWidth === ScreenWidths.SM; + const tokenColSpan = isSmallScreen ? TOKEN_COL_SPAN_SM : TOKEN_COL_SPAN_LG; + const dharmaLoanExplanation = 'If you need access to larger amounts of ether,
\ + you can request a loan from the Dharma Loan
\ + network. Your loan should be funded in 5
\ + minutes or less.'; + const allowanceExplanation = '0x smart contracts require access to your
\ + token balances in order to execute trades.
\ + Toggling permissions sets an allowance for the
\ + smart contract so you can start trading that token.'; + return ( +
+

{isTestNetwork ? 'Test ether' : 'Ether'}

+ +
+ {isTestNetwork ? + 'In order to try out the 0x Portal Dapp, request some test ether to pay for \ + gas costs. It might take a bit of time for the test ether to show up.' : + 'Ether must be converted to Ether Tokens in order to be tradable via 0x. \ + You can convert between Ether and Ether Tokens by clicking the "convert" button below.' + } +
+ + + + Currency + Balance + + { + isTestNetwork && + + {isSmallScreen ? 'Faucet' : 'Request from faucet'} + + } + { + isTestNetwork && + + {isSmallScreen ? 'Loan' : 'Request Dharma loan'} + + + } + + + + + + + + + {this.props.userEtherBalance.toFixed(PRECISION)} ETH + {this.state.isBalanceSpinnerVisible && + + + + } + + + { + isTestNetwork && + + + + } + { + isTestNetwork && + + + + } + + +
+
+
+

+ {isTestNetwork ? 'Test tokens' : 'Tokens'} +

+
+
+ + + +
+
+ + + +
+
+ +
+ {isTestNetwork ? + 'Mint some test tokens you\'d like to use to generate or fill an order using 0x.' : + 'Set trading permissions for a token you\'d like to start trading.' + } +
+ + + + + Token + + Balance + +
{!isSmallScreen && 'Trade '}Permissions
+ +
+ + Action + + {this.props.screenWidth !== ScreenWidths.SM && + + Send + + } +
+
+ + {this.renderTokenTableRows()} + +
+ + {this.renderErrorDialogBody()} + + + {this.renderDharmaLoanFrame()} + + +
+ ); + } + private renderTokenTableRows() { + if (!this.props.blockchainIsLoaded || this.props.blockchainErr !== '') { + return ''; + } + const isSmallScreen = this.props.screenWidth === ScreenWidths.SM; + const tokenColSpan = isSmallScreen ? TOKEN_COL_SPAN_SM : TOKEN_COL_SPAN_LG; + const actionPaddingX = isSmallScreen ? 2 : 24; + const allTokens = _.values(this.props.tokenByAddress); + const trackedTokens = _.filter(allTokens, t => t.isTracked); + const trackedTokensStartingWithEtherToken = trackedTokens.sort( + firstBy((t: Token) => (t.symbol !== ETHER_TOKEN_SYMBOL)) + .thenBy((t: Token) => (t.symbol !== ZRX_TOKEN_SYMBOL)) + .thenBy('address'), + ); + const tableRows = _.map( + trackedTokensStartingWithEtherToken, + this.renderTokenRow.bind(this, tokenColSpan, actionPaddingX), + ); + return tableRows; + } + private renderTokenRow(tokenColSpan: number, actionPaddingX: number, token: Token) { + const tokenState = this.props.tokenStateByAddress[token.address]; + const tokenLink = utils.getEtherScanLinkIfExists(token.address, this.props.networkId, + EtherscanLinkSuffixes.address); + const isMintable = _.includes(configs.symbolsOfMintableTokens, token.symbol) && + this.props.networkId !== constants.MAINNET_NETWORK_ID; + return ( + + + {_.isUndefined(tokenLink) ? + this.renderTokenName(token) : + + {this.renderTokenName(token)} + + } + + + {this.renderAmount(tokenState.balance, token.decimals)} {token.symbol} + {this.state.isZRXSpinnerVisible && token.symbol === ZRX_TOKEN_SYMBOL && + + + + } + + + + + + {isMintable && + Minting...} + labelComplete="Minted!" + onClickAsyncFn={this.onMintTestTokensAsync.bind(this, token)} + /> + } + {token.symbol === ETHER_TOKEN_SYMBOL && + + } + {token.symbol === ZRX_TOKEN_SYMBOL && this.props.networkId === constants.TESTNET_NETWORK_ID && + + } + + {this.props.screenWidth !== ScreenWidths.SM && + + + + } + + ); + } + private onAssetTokenPicked(tokenAddress: string) { + if (_.isEmpty(tokenAddress)) { + this.setState({ + isTokenPickerOpen: false, + }); + return; + } + const token = this.props.tokenByAddress[tokenAddress]; + const isDefaultTrackedToken = _.includes(configs.defaultTrackedTokenSymbols, token.symbol); + if (!this.state.isAddingToken && !isDefaultTrackedToken) { + if (token.isRegistered) { + // Remove the token from tracked tokens + const newToken = _.assign({}, token, { + isTracked: false, + }); + this.props.dispatcher.updateTokenByAddress([newToken]); + } else { + this.props.dispatcher.removeTokenToTokenByAddress(token); + } + this.props.dispatcher.removeFromTokenStateByAddress(tokenAddress); + trackedTokenStorage.removeTrackedToken(this.props.userAddress, this.props.networkId, tokenAddress); + } else if (isDefaultTrackedToken) { + this.props.dispatcher.showFlashMessage(`Cannot remove ${token.name} because it's a default token`); + } + this.setState({ + isTokenPickerOpen: false, + }); + } + private onEthWethConversionFailed() { + this.setState({ + errorType: BalanceErrs.wethConversionFailed, + }); + } + private onSendFailed() { + this.setState({ + errorType: BalanceErrs.sendFailed, + }); + } + private renderAmount(amount: BigNumber, decimals: number) { + const unitAmount = ZeroEx.toUnitAmount(amount, decimals); + return unitAmount.toNumber().toFixed(PRECISION); + } + private renderTokenName(token: Token) { + const tooltipId = `tooltip-${token.address}`; + return ( +
+ +
+ {token.name} +
+ {token.address} +
+ ); + } + private renderErrorDialogBody() { + switch (this.state.errorType) { + case BalanceErrs.incorrectNetworkForFaucet: + return ( +
+ Our faucet can only send test Ether to addresses on the {constants.TESTNET_NAME} + {' '}testnet (networkId {constants.TESTNET_NETWORK_ID}). Please make sure you are + {' '}connected to the {constants.TESTNET_NAME} testnet and try requesting ether again. +
+ ); + + case BalanceErrs.faucetRequestFailed: + return ( +
+ An unexpected error occurred while trying to request test Ether from our faucet. + {' '}Please refresh the page and try again. +
+ ); + + case BalanceErrs.faucetQueueIsFull: + return ( +
+ Our test Ether faucet queue is full. Please try requesting test Ether again later. +
+ ); + + case BalanceErrs.mintingFailed: + return ( +
+ Minting your test tokens failed unexpectedly. Please refresh the page and try again. +
+ ); + + case BalanceErrs.wethConversionFailed: + return ( +
+ Converting between Ether and Ether Tokens failed unexpectedly. + Please refresh the page and try again. +
+ ); + + case BalanceErrs.allowanceSettingFailed: + return ( +
+ An unexpected error occurred while trying to set your test token allowance. + {' '}Please refresh the page and try again. +
+ ); + + case undefined: + return null; // No error to show + + default: + throw utils.spawnSwitchErr('errorType', this.state.errorType); + } + } + private renderDharmaLoanFrame() { + if (utils.isUserOnMobile()) { + return ( +

+ We apologize -- Dharma loan requests are not available on + mobile yet. Please try again through your desktop browser. +

+ ); + } else { + return ( + + ); + } + } + private onErrorOccurred(errorType: BalanceErrs) { + this.setState({ + errorType, + }); + } + private async onMintTestTokensAsync(token: Token): Promise { + try { + await this.props.blockchain.mintTestTokensAsync(token); + const amount = ZeroEx.toUnitAmount(constants.MINT_AMOUNT, token.decimals); + this.props.dispatcher.showFlashMessage(`Successfully minted ${amount.toString(10)} ${token.symbol}`); + return true; + } catch (err) { + const errMsg = '' + err; + if (_.includes(errMsg, BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return false; + } + if (_.includes(errMsg, 'User denied transaction')) { + return false; + } + utils.consoleLog(`Unexpected error encountered: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + this.setState({ + errorType: BalanceErrs.mintingFailed, + }); + return false; + } + } + private async faucetRequestAsync(isEtherRequest: boolean): Promise { + if (this.props.userAddress === '') { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return false; + } + + // If on another network other then the testnet our faucet serves test ether + // from, we must show user an error message + if (this.props.blockchain.networkId !== constants.TESTNET_NETWORK_ID) { + this.setState({ + errorType: BalanceErrs.incorrectNetworkForFaucet, + }); + return false; + } + + await utils.sleepAsync(ARTIFICIAL_FAUCET_REQUEST_DELAY); + + const segment = isEtherRequest ? 'ether' : 'zrx'; + const response = await fetch(`${constants.ETHER_FAUCET_ENDPOINT}/${segment}/${this.props.userAddress}`); + const responseBody = await response.text(); + if (response.status !== constants.SUCCESS_STATUS) { + utils.consoleLog(`Unexpected status code: ${response.status} -> ${responseBody}`); + await errorReporter.reportAsync(new Error(`Faucet returned non-200: ${JSON.stringify(response)}`)); + const errorType = response.status === constants.UNAVAILABLE_STATUS ? + BalanceErrs.faucetQueueIsFull : + BalanceErrs.faucetRequestFailed; + this.setState({ + errorType, + }); + return false; + } + + if (isEtherRequest) { + this.setState({ + isBalanceSpinnerVisible: true, + }); + } else { + const tokens = _.values(this.props.tokenByAddress); + const zrxToken = _.find(tokens, t => t.symbol === ZRX_TOKEN_SYMBOL); + const zrxTokenState = this.props.tokenStateByAddress[zrxToken.address]; + this.setState({ + isZRXSpinnerVisible: true, + currentZrxBalance: zrxTokenState.balance, + }); + this.props.blockchain.pollTokenBalanceAsync(zrxToken); + } + return true; + } + private onErrorDialogToggle(isOpen: boolean) { + this.setState({ + errorType: undefined, + }); + } + private onDharmaDialogToggle() { + this.setState({ + isDharmaDialogVisible: !this.state.isDharmaDialogVisible, + }); + } + private getWrappedEthToken() { + const tokens = _.values(this.props.tokenByAddress); + const wrappedEthToken = _.find(tokens, {symbol: ETHER_TOKEN_SYMBOL}); + return wrappedEthToken; + } + private onAddTokenClicked() { + this.setState({ + isTokenPickerOpen: true, + isAddingToken: true, + }); + } + private onRemoveTokenClicked() { + this.setState({ + isTokenPickerOpen: true, + isAddingToken: false, + }); + } +} diff --git a/packages/website/ts/components/top_bar.tsx b/packages/website/ts/components/top_bar.tsx new file mode 100644 index 000000000..6248095b3 --- /dev/null +++ b/packages/website/ts/components/top_bar.tsx @@ -0,0 +1,370 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { + Link as ScrollLink, + animateScroll, +} from 'react-scroll'; +import {Link} from 'react-router-dom'; +import {HashLink} from 'react-router-hash-link'; +import AppBar from 'material-ui/AppBar'; +import Drawer from 'material-ui/Drawer'; +import MenuItem from 'material-ui/MenuItem'; +import {colors} from 'material-ui/styles'; +import ReactTooltip = require('react-tooltip'); +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; +import {Identicon} from 'ts/components/ui/identicon'; +import {NestedSidebarMenu} from 'ts/pages/shared/nested_sidebar_menu'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; +import {PortalMenu} from 'ts/components/portal_menu'; +import {Styles, TypeDocNode, MenuSubsectionsBySection, WebsitePaths, Docs} from 'ts/types'; +import {TopBarMenuItem} from 'ts/components/top_bar_menu_item'; +import {DropDownMenuItem} from 'ts/components/ui/drop_down_menu_item'; + +const CUSTOM_DARK_GRAY = '#231F20'; +const SECTION_HEADER_COLOR = 'rgb(234, 234, 234)'; + +interface TopBarProps { + userAddress?: string; + blockchainIsLoaded: boolean; + location: Location; + docsVersion?: string; + availableDocVersions?: string[]; + menuSubsectionsBySection?: MenuSubsectionsBySection; + shouldFullWidth?: boolean; + doc?: Docs; + style?: React.CSSProperties; + isNightVersion?: boolean; +} + +interface TopBarState { + isDrawerOpen: boolean; +} + +const styles: Styles = { + address: { + marginRight: 12, + overflow: 'hidden', + paddingTop: 4, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: 70, + }, + addressPopover: { + backgroundColor: colors.blueGrey500, + color: 'white', + padding: 3, + }, + topBar: { + backgroundColor: 'white', + height: 59, + width: '100%', + position: 'fixed', + top: 0, + zIndex: 1100, + paddingBottom: 1, + }, + bottomBar: { + boxShadow: 'rgba(0, 0, 0, 0.187647) 0px 1px 3px', + }, + menuItem: { + fontSize: 14, + color: CUSTOM_DARK_GRAY, + paddingTop: 6, + paddingBottom: 6, + marginTop: 17, + cursor: 'pointer', + fontWeight: 400, + }, +}; + +export class TopBar extends React.Component { + public static defaultProps: Partial = { + shouldFullWidth: false, + style: {}, + isNightVersion: false, + }; + constructor(props: TopBarProps) { + super(props); + this.state = { + isDrawerOpen: false, + }; + } + public render() { + const isNightVersion = this.props.isNightVersion; + const isFullWidthPage = this.props.shouldFullWidth; + const parentClassNames = `flex mx-auto ${isFullWidthPage ? 'pl2' : 'max-width-4'}`; + const developerSectionMenuItems = [ + + + , + + + , + + + , + + + , + + + , + ]; + const bottomBorderStyle = this.shouldDisplayBottomBar() ? styles.bottomBar : {}; + const fullWithClassNames = isFullWidthPage ? 'pr4' : ''; + const logoUrl = isNightVersion ? '/images/protocol_logo_white.png' : '/images/protocol_logo_black.png'; + return ( +
+
+
+ + + +
+
+
+ {!this.isViewingPortal() && +
+
+ + + + +
+
+ } + {this.props.blockchainIsLoaded && !_.isEmpty(this.props.userAddress) && +
+ {this.renderUser()} +
+ } + {!this.isViewingPortal() && +
+
+ +
+
+ } +
+ {this.renderDrawer()} +
+ ); + } + private renderDrawer() { + return ( + + {this.renderPortalMenu()} + {this.renderDocsMenu()} + {this.renderWiki()} +
Website
+ + Home + + + Wiki + + {!this.isViewing0xjsDocs() && + + 0x.js Docs + + } + {!this.isViewingSmartContractsDocs() && + + Smart Contract Docs + + } + {!this.isViewingPortal() && + + Portal DApp + + } + + Whitepaper + + + About + + + Blog + + + + FAQ + + +
+ ); + } + private renderDocsMenu() { + if (!this.isViewing0xjsDocs() && !this.isViewingSmartContractsDocs()) { + return; + } + + const topLevelMenu = this.isViewing0xjsDocs() ? + typeDocUtils.getFinal0xjsMenu(this.props.docsVersion) : + constants.menuSmartContracts; + + const sectionTitle = this.isViewing0xjsDocs() ? '0x.js Docs' : 'Smart contract Docs'; + return ( +
+
{sectionTitle}
+ +
+ ); + } + private renderWiki() { + if (!this.isViewingWiki()) { + return; + } + + return ( +
+
0x Protocol Wiki
+ +
+ ); + } + private renderPortalMenu() { + if (!this.isViewingPortal()) { + return; + } + + return ( +
+
Portal DApp
+ +
+ ); + } + private renderUser() { + const userAddress = this.props.userAddress; + const identiconDiameter = 26; + return ( +
+
+ {!_.isEmpty(userAddress) ? userAddress : ''} +
+ {userAddress} +
+ +
+
+ ); + } + private onMenuButtonClick() { + this.setState({ + isDrawerOpen: !this.state.isDrawerOpen, + }); + } + private isViewingPortal() { + return _.includes(this.props.location.pathname, WebsitePaths.Portal); + } + private isViewingFAQ() { + return _.includes(this.props.location.pathname, WebsitePaths.FAQ); + } + private isViewing0xjsDocs() { + return _.includes(this.props.location.pathname, WebsitePaths.ZeroExJs); + } + private isViewingSmartContractsDocs() { + return _.includes(this.props.location.pathname, WebsitePaths.SmartContracts); + } + private isViewingWiki() { + return _.includes(this.props.location.pathname, WebsitePaths.Wiki); + } + private shouldDisplayBottomBar() { + return this.isViewingWiki() || this.isViewing0xjsDocs() || this.isViewingFAQ() || + this.isViewingSmartContractsDocs(); + } +} diff --git a/packages/website/ts/components/top_bar_menu_item.tsx b/packages/website/ts/components/top_bar_menu_item.tsx new file mode 100644 index 000000000..de429fba6 --- /dev/null +++ b/packages/website/ts/components/top_bar_menu_item.tsx @@ -0,0 +1,53 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Link} from 'react-router-dom'; +import {Styles} from 'ts/types'; + +const CUSTOM_DARK_GRAY = '#231F20'; +const DEFAULT_STYLE = { + color: CUSTOM_DARK_GRAY, +}; + +interface TopBarMenuItemProps { + title: string; + path?: string; + isPrimary?: boolean; + style?: React.CSSProperties; + className?: string; + isNightVersion?: boolean; +} + +interface TopBarMenuItemState {} + +export class TopBarMenuItem extends React.Component { + public static defaultProps: Partial = { + isPrimary: false, + style: DEFAULT_STYLE, + className: '', + isNightVersion: false, + }; + public render() { + const primaryStyles = this.props.isPrimary ? { + borderRadius: 4, + border: `1px solid ${this.props.isNightVersion ? '#979797' : 'rgb(230, 229, 229)'}`, + marginTop: 15, + paddingLeft: 9, + paddingRight: 9, + width: 77, + } : {}; + const menuItemColor = this.props.isNightVersion ? 'white' : this.props.style.color; + const linkColor = _.isUndefined(menuItemColor) ? + CUSTOM_DARK_GRAY : + menuItemColor; + return ( +
+ + {this.props.title} + +
+ ); + } +} diff --git a/packages/website/ts/components/track_token_confirmation.tsx b/packages/website/ts/components/track_token_confirmation.tsx new file mode 100644 index 000000000..bc036eae3 --- /dev/null +++ b/packages/website/ts/components/track_token_confirmation.tsx @@ -0,0 +1,65 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import FlatButton from 'material-ui/FlatButton'; +import Dialog from 'material-ui/Dialog'; +import {utils} from 'ts/utils/utils'; +import {Party} from 'ts/components/ui/party'; +import {Token, TokenByAddress} from 'ts/types'; + +interface TrackTokenConfirmationProps { + tokens: Token[]; + tokenByAddress: TokenByAddress; + networkId: number; + isAddingTokenToTracked: boolean; +} + +interface TrackTokenConfirmationState {} + +export class TrackTokenConfirmation extends + React.Component { + public render() { + const isMultipleTokens = this.props.tokens.length > 1; + const allTokens = _.values(this.props.tokenByAddress); + return ( +
+ {this.props.isAddingTokenToTracked ? +
+ + + + Adding token{isMultipleTokens && 's'}... +
: +
+
+ You do not currently track the following token{isMultipleTokens && 's'}: +
+
+ {_.map(this.props.tokens, (token: Token) => ( +
+ +
+ ))} +
+
+ Tracking a token adds it to the balances section of 0x Portal and + allows you to generate/fill orders involving the token + {isMultipleTokens && 's'}. Would you like to start tracking{' '} + {isMultipleTokens ? 'these' : 'this'}{' '}token? +
+
+ } +
+ ); + } +} diff --git a/packages/website/ts/components/trade_history/trade_history.tsx b/packages/website/ts/components/trade_history/trade_history.tsx new file mode 100644 index 000000000..9deaf8fd8 --- /dev/null +++ b/packages/website/ts/components/trade_history/trade_history.tsx @@ -0,0 +1,115 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import Paper from 'material-ui/Paper'; +import Divider from 'material-ui/Divider'; +import {utils} from 'ts/utils/utils'; +import {Fill, TokenByAddress} from 'ts/types'; +import {TradeHistoryItem} from 'ts/components/trade_history/trade_history_item'; +import {tradeHistoryStorage} from 'ts/local_storage/trade_history_storage'; + +const FILL_POLLING_INTERVAL = 1000; + +interface TradeHistoryProps { + tokenByAddress: TokenByAddress; + userAddress: string; + networkId: number; +} + +interface TradeHistoryState { + sortedFills: Fill[]; +} + +export class TradeHistory extends React.Component { + private fillPollingIntervalId: number; + public constructor(props: TradeHistoryProps) { + super(props); + const sortedFills = this.getSortedFills(); + this.state = { + sortedFills, + }; + } + public componentWillMount() { + this.startPollingForFills(); + } + public componentWillUnmount() { + this.stopPollingForFills(); + } + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + return ( +
+

Trade history

+ +
+ {this.renderTrades()} +
+
+ ); + } + private renderTrades() { + const numNonCustomFills = this.numFillsWithoutCustomERC20Tokens(); + if (numNonCustomFills === 0) { + return this.renderEmptyNotice(); + } + + return _.map(this.state.sortedFills, (fill, index) => { + return ( + + ); + }); + } + private renderEmptyNotice() { + return ( + + No filled orders yet. + + ); + } + private numFillsWithoutCustomERC20Tokens() { + let numNonCustomFills = 0; + const tokens = _.values(this.props.tokenByAddress); + _.each(this.state.sortedFills, fill => { + const takerToken = _.find(tokens, token => { + return token.address === fill.takerToken; + }); + const makerToken = _.find(tokens, token => { + return token.address === fill.makerToken; + }); + // For now we don't show history items for orders using custom ERC20 + // tokens the client does not know how to display. + // TODO: Try to retrieve the name/symbol of an unknown token in order to display it + // Be sure to remove similar logic in trade_history_item.tsx + if (!_.isUndefined(takerToken) && !_.isUndefined(makerToken)) { + numNonCustomFills += 1; + } + }); + return numNonCustomFills; + } + private startPollingForFills() { + this.fillPollingIntervalId = window.setInterval(() => { + const sortedFills = this.getSortedFills(); + if (!utils.deepEqual(sortedFills, this.state.sortedFills)) { + this.setState({ + sortedFills, + }); + } + }, FILL_POLLING_INTERVAL); + } + private stopPollingForFills() { + clearInterval(this.fillPollingIntervalId); + } + private getSortedFills() { + const fillsByHash = tradeHistoryStorage.getUserFillsByHash(this.props.userAddress, this.props.networkId); + const fills = _.values(fillsByHash); + const sortedFills = _.sortBy(fills, [(fill: Fill) => fill.blockTimestamp * -1]); + return sortedFills; + } +} diff --git a/packages/website/ts/components/trade_history/trade_history_item.tsx b/packages/website/ts/components/trade_history/trade_history_item.tsx new file mode 100644 index 000000000..96b755e3c --- /dev/null +++ b/packages/website/ts/components/trade_history/trade_history_item.tsx @@ -0,0 +1,178 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import BigNumber from 'bignumber.js'; +import * as ReactTooltip from 'react-tooltip'; +import * as moment from 'moment'; +import Paper from 'material-ui/Paper'; +import {colors} from 'material-ui/styles'; +import {ZeroEx} from '0x.js'; +import {TokenByAddress, Fill, Token, EtherscanLinkSuffixes} from 'ts/types'; +import {Party} from 'ts/components/ui/party'; +import {EtherScanIcon} from 'ts/components/ui/etherscan_icon'; + +const PRECISION = 5; +const IDENTICON_DIAMETER = 40; + +interface TradeHistoryItemProps { + fill: Fill; + tokenByAddress: TokenByAddress; + userAddress: string; + networkId: number; +} + +interface TradeHistoryItemState {} + +export class TradeHistoryItem extends React.Component { + public render() { + const fill = this.props.fill; + const tokens = _.values(this.props.tokenByAddress); + const takerToken = _.find(tokens, token => { + return token.address === fill.takerToken; + }); + const makerToken = _.find(tokens, token => { + return token.address === fill.makerToken; + }); + // For now we don't show history items for orders using custom ERC20 + // tokens the client does not know how to display. + // TODO: Try to retrieve the name/symbol of an unknown token in order to display it + // Be sure to remove similar logic in trade_history.tsx + if (_.isUndefined(takerToken) || _.isUndefined(makerToken)) { + return null; + } + + const amountColStyle: React.CSSProperties = { + fontWeight: 100, + display: 'inline-block', + }; + const amountColClassNames = 'col col-12 lg-col-4 md-col-4 lg-py2 md-py2 sm-py1 lg-pr2 md-pr2 \ + lg-right-align md-right-align sm-center'; + + return ( + +
+
+ {this.renderDate()} +
+
+
+ + + +
+
+
+ {this.renderAmounts(makerToken, takerToken)} +
+
+
+ +
+
+
+
+ ); + } + private renderAmounts(makerToken: Token, takerToken: Token) { + const fill = this.props.fill; + const filledTakerTokenAmountInUnits = ZeroEx.toUnitAmount(fill.filledTakerTokenAmount, takerToken.decimals); + const filledMakerTokenAmountInUnits = ZeroEx.toUnitAmount(fill.filledMakerTokenAmount, takerToken.decimals); + let exchangeRate = filledTakerTokenAmountInUnits.div(filledMakerTokenAmountInUnits); + const fillMakerTokenAmount = ZeroEx.toBaseUnitAmount(filledMakerTokenAmountInUnits, makerToken.decimals); + + let receiveAmount; + let receiveToken; + let givenAmount; + let givenToken; + if (this.props.userAddress === fill.maker && this.props.userAddress === fill.taker) { + receiveAmount = new BigNumber(0); + givenAmount = new BigNumber(0); + receiveToken = makerToken; + givenToken = takerToken; + } else if (this.props.userAddress === fill.maker) { + receiveAmount = fill.filledTakerTokenAmount; + givenAmount = fillMakerTokenAmount; + receiveToken = takerToken; + givenToken = makerToken; + exchangeRate = new BigNumber(1).div(exchangeRate); + } else if (this.props.userAddress === fill.taker) { + receiveAmount = fillMakerTokenAmount; + givenAmount = fill.filledTakerTokenAmount; + receiveToken = makerToken; + givenToken = takerToken; + } + + return ( +
+
+ +{' '} + {this.renderAmount(receiveAmount, receiveToken.symbol, receiveToken.decimals)} +
+
+ -{' '} + {this.renderAmount(givenAmount, givenToken.symbol, givenToken.decimals)} +
+
+ {exchangeRate.toFixed(PRECISION)} {givenToken.symbol}/{receiveToken.symbol} +
+
+ ); + } + private renderDate() { + const blockMoment = moment.unix(this.props.fill.blockTimestamp); + if (!blockMoment.isValid()) { + return null; + } + + const dayOfMonth = blockMoment.format('D'); + const monthAbreviation = blockMoment.format('MMM'); + const formattedBlockDate = blockMoment.format('H:mmA - MMMM D, YYYY'); + const dateTooltipId = `${this.props.fill.transactionHash}-date`; + + return ( +
+
{monthAbreviation}
+
{dayOfMonth}
+ {formattedBlockDate} +
+ ); + } + private renderAmount(amount: BigNumber, symbol: string, decimals: number) { + const unitAmount = ZeroEx.toUnitAmount(amount, decimals); + return ( + + {unitAmount.toFixed(PRECISION)} {symbol} + + ); + } +} diff --git a/packages/website/ts/components/ui/alert.tsx b/packages/website/ts/components/ui/alert.tsx new file mode 100644 index 000000000..bf2f0baf5 --- /dev/null +++ b/packages/website/ts/components/ui/alert.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {AlertTypes} from 'ts/types'; + +const CUSTOM_GREEN = 'rgb(137, 199, 116)'; + +interface AlertProps { + type: AlertTypes; + message: string|React.ReactNode; +} + +export function Alert(props: AlertProps) { + const isAlert = props.type === AlertTypes.ERROR; + const errMsgStyles = { + background: isAlert ? colors.red200 : CUSTOM_GREEN, + color: 'white', + marginTop: 10, + padding: 4, + paddingLeft: 8, + }; + + return ( +
+ {props.message} +
+ ); +} diff --git a/packages/website/ts/components/ui/badge.tsx b/packages/website/ts/components/ui/badge.tsx new file mode 100644 index 000000000..1e3bbdb99 --- /dev/null +++ b/packages/website/ts/components/ui/badge.tsx @@ -0,0 +1,58 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {Styles} from 'ts/types'; + +const styles: Styles = { + badge: { + width: 50, + fontSize: 11, + height: 10, + borderRadius: 5, + marginTop: 25, + lineHeight: 0.9, + fontFamily: 'Roboto Mono', + marginLeft: 3, + marginRight: 3, + }, +}; + +interface BadgeProps { + title: string; + backgroundColor: string; +} + +interface BadgeState { + isHovering: boolean; +} + +export class Badge extends React.Component { + constructor(props: BadgeProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public render() { + const badgeStyle = { + ...styles.badge, + backgroundColor: this.props.backgroundColor, + opacity: this.state.isHovering ? 0.7 : 1, + }; + return ( +
+ {this.props.title} +
+ ); + } + private setHoverState(isHovering: boolean) { + this.setState({ + isHovering, + }); + } +} diff --git a/packages/website/ts/components/ui/copy_icon.tsx b/packages/website/ts/components/ui/copy_icon.tsx new file mode 100644 index 000000000..f8abaa59e --- /dev/null +++ b/packages/website/ts/components/ui/copy_icon.tsx @@ -0,0 +1,81 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as CopyToClipboard from 'react-copy-to-clipboard'; +import {colors} from 'material-ui/styles'; +import ReactTooltip = require('react-tooltip'); + +interface CopyIconProps { + data: string; + callToAction?: string; +} + +interface CopyIconState { + isHovering: boolean; +} + +export class CopyIcon extends React.Component { + private copyTooltipTimeoutId: number; + private copyable: HTMLInputElement; + constructor(props: CopyIconProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public componentDidUpdate() { + // Remove tooltip if hover away + if (!this.state.isHovering && this.copyTooltipTimeoutId) { + clearInterval(this.copyTooltipTimeoutId); + this.hideTooltip(); + } + } + public render() { + return ( +
+ +
+
+ +
+ {this.props.callToAction && +
{this.props.callToAction}
+ } +
+
+ Copied! +
+ ); + } + private setRefToProperty(el: HTMLInputElement) { + this.copyable = el; + } + private setHoverState(isHovering: boolean) { + this.setState({ + isHovering, + }); + } + private onCopy() { + if (this.copyTooltipTimeoutId) { + clearInterval(this.copyTooltipTimeoutId); + } + + const tooltipLifespanMs = 1000; + this.copyTooltipTimeoutId = window.setTimeout(() => { + this.hideTooltip(); + }, tooltipLifespanMs); + } + private hideTooltip() { + ReactTooltip.hide(ReactDOM.findDOMNode(this.copyable)); + } +} diff --git a/packages/website/ts/components/ui/drop_down_menu_item.tsx b/packages/website/ts/components/ui/drop_down_menu_item.tsx new file mode 100644 index 000000000..b8b7eb167 --- /dev/null +++ b/packages/website/ts/components/ui/drop_down_menu_item.tsx @@ -0,0 +1,117 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { + Link as ScrollLink, +} from 'react-scroll'; +import {Link} from 'react-router-dom'; +import Popover from 'material-ui/Popover'; +import Menu from 'material-ui/Menu'; +import MenuItem from 'material-ui/MenuItem'; +import {Styles, WebsitePaths} from 'ts/types'; + +const CHECK_CLOSE_POPOVER_INTERVAL_MS = 300; +const CUSTOM_LIGHT_GRAY = '#848484'; +const DEFAULT_STYLE = { + fontSize: 14, +}; + +interface DropDownMenuItemProps { + title: string; + subMenuItems: React.ReactNode[]; + style?: React.CSSProperties; + menuItemStyle?: React.CSSProperties; + isNightVersion?: boolean; +} + +interface DropDownMenuItemState { + isDropDownOpen: boolean; + anchorEl?: HTMLInputElement; +} + +export class DropDownMenuItem extends React.Component { + public static defaultProps: Partial = { + style: DEFAULT_STYLE, + menuItemStyle: DEFAULT_STYLE, + isNightVersion: false, + }; + private isHovering: boolean; + private popoverCloseCheckIntervalId: number; + constructor(props: DropDownMenuItemProps) { + super(props); + this.state = { + isDropDownOpen: false, + }; + } + public componentDidMount() { + this.popoverCloseCheckIntervalId = window.setInterval(() => { + this.checkIfShouldClosePopover(); + }, CHECK_CLOSE_POPOVER_INTERVAL_MS); + } + public componentWillUnmount() { + window.clearInterval(this.popoverCloseCheckIntervalId); + } + public render() { + const colorStyle = this.props.isNightVersion ? 'white' : this.props.style.color; + return ( +
+
+
+ {this.props.title} +
+
+ +
+
+ +
+ + {this.props.subMenuItems} + +
+
+
+ ); + } + private onHover(event: React.FormEvent) { + this.isHovering = true; + this.checkIfShouldOpenPopover(event); + } + private checkIfShouldOpenPopover(event: React.FormEvent) { + if (this.state.isDropDownOpen) { + return; // noop + } + + this.setState({ + isDropDownOpen: true, + anchorEl: event.currentTarget, + }); + } + private onHoverOff(event: React.FormEvent) { + this.isHovering = false; + } + private checkIfShouldClosePopover() { + if (!this.state.isDropDownOpen || this.isHovering) { + return; // noop + } + this.closePopover(); + } + private closePopover() { + this.setState({ + isDropDownOpen: false, + }); + } +} diff --git a/packages/website/ts/components/ui/ethereum_address.tsx b/packages/website/ts/components/ui/ethereum_address.tsx new file mode 100644 index 000000000..c3d03b78c --- /dev/null +++ b/packages/website/ts/components/ui/ethereum_address.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {EtherScanIcon} from 'ts/components/ui/etherscan_icon'; +import ReactTooltip = require('react-tooltip'); +import {EtherscanLinkSuffixes} from 'ts/types'; +import {utils} from 'ts/utils/utils'; + +interface EthereumAddressProps { + address: string; + networkId: number; +} + +export const EthereumAddress = (props: EthereumAddressProps) => { + const tooltipId = `${props.address}-ethereum-address`; + const truncatedAddress = utils.getAddressBeginAndEnd(props.address); + return ( +
+
+ {truncatedAddress} +
+
+ +
+ {props.address} +
+ ); +}; diff --git a/packages/website/ts/components/ui/etherscan_icon.tsx b/packages/website/ts/components/ui/etherscan_icon.tsx new file mode 100644 index 000000000..12044f44b --- /dev/null +++ b/packages/website/ts/components/ui/etherscan_icon.tsx @@ -0,0 +1,50 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import ReactTooltip = require('react-tooltip'); +import {colors} from 'material-ui/styles'; +import {EtherscanLinkSuffixes} from 'ts/types'; +import {utils} from 'ts/utils/utils'; + +interface EtherScanIconProps { + addressOrTxHash: string; + etherscanLinkSuffixes: EtherscanLinkSuffixes; + networkId: number; +} + +export const EtherScanIcon = (props: EtherScanIconProps) => { + const etherscanLinkIfExists = utils.getEtherScanLinkIfExists( + props.addressOrTxHash, props.networkId, EtherscanLinkSuffixes.address, + ); + const transactionTooltipId = `${props.addressOrTxHash}-etherscan-icon-tooltip`; + return ( +
+ {!_.isUndefined(etherscanLinkIfExists) ? + + {renderIcon()} + : +
+ {renderIcon()} + + Your network (id: {props.networkId}) is not supported by Etherscan + +
+ } +
+ ); +}; + +function renderIcon() { + return ( + + ); +} diff --git a/packages/website/ts/components/ui/fake_text_field.tsx b/packages/website/ts/components/ui/fake_text_field.tsx new file mode 100644 index 000000000..372785c2f --- /dev/null +++ b/packages/website/ts/components/ui/fake_text_field.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {InputLabel} from 'ts/components/ui/input_label'; +import {Styles} from 'ts/types'; + +const styles: Styles = { + hr: { + borderBottom: '1px solid rgb(224, 224, 224)', + borderLeft: 'none rgb(224, 224, 224)', + borderRight: 'none rgb(224, 224, 224)', + borderTop: 'none rgb(224, 224, 224)', + bottom: 6, + boxSizing: 'content-box', + margin: 0, + position: 'absolute', + width: '100%', + }, +}; + +interface FakeTextFieldProps { + label?: React.ReactNode | string; + children?: any; +} + +export function FakeTextField(props: FakeTextFieldProps) { + return ( +
+ {props.label !== '' && } +
+ {props.children} +
+
+
+ ); +} diff --git a/packages/website/ts/components/ui/flash_message.tsx b/packages/website/ts/components/ui/flash_message.tsx new file mode 100644 index 000000000..684aeef68 --- /dev/null +++ b/packages/website/ts/components/ui/flash_message.tsx @@ -0,0 +1,40 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import Snackbar from 'material-ui/Snackbar'; +import {Dispatcher} from 'ts/redux/dispatcher'; + +const SHOW_DURATION_MS = 4000; + +interface FlashMessageProps { + dispatcher: Dispatcher; + flashMessage?: string|React.ReactNode; + showDurationMs?: number; + bodyStyle?: React.CSSProperties; +} + +interface FlashMessageState {} + +export class FlashMessage extends React.Component { + public static defaultProps: Partial = { + showDurationMs: SHOW_DURATION_MS, + bodyStyle: {}, + }; + public render() { + if (!_.isUndefined(this.props.flashMessage)) { + return ( + + ); + } else { + return null; + } + } + private onClose() { + this.props.dispatcher.hideFlashMessage(); + } +} diff --git a/packages/website/ts/components/ui/help_tooltip.tsx b/packages/website/ts/components/ui/help_tooltip.tsx new file mode 100644 index 000000000..003b795ef --- /dev/null +++ b/packages/website/ts/components/ui/help_tooltip.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import ReactTooltip = require('react-tooltip'); + +interface HelpTooltipProps { + style?: React.CSSProperties; + explanation: React.ReactNode; +} + +export const HelpTooltip = (props: HelpTooltipProps) => { + return ( +
+ + +
+ ); +}; diff --git a/packages/website/ts/components/ui/identicon.tsx b/packages/website/ts/components/ui/identicon.tsx new file mode 100644 index 000000000..814548fb4 --- /dev/null +++ b/packages/website/ts/components/ui/identicon.tsx @@ -0,0 +1,36 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {constants} from 'ts/utils/constants'; +import blockies = require('blockies'); + +interface IdenticonProps { + address: string; + diameter: number; + style?: React.CSSProperties; +} + +interface IdenticonState {} + +export class Identicon extends React.Component { + public static defaultProps: Partial = { + style: {}, + }; + public render() { + let address = this.props.address; + if (_.isEmpty(address)) { + address = constants.NULL_ADDRESS; + } + const diameter = this.props.diameter; + const icon = blockies({ + seed: address.toLowerCase(), + }); + return ( +
+ +
+ ); + } +} diff --git a/packages/website/ts/components/ui/input_label.tsx b/packages/website/ts/components/ui/input_label.tsx new file mode 100644 index 000000000..5866c70b6 --- /dev/null +++ b/packages/website/ts/components/ui/input_label.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; + +export interface InputLabelProps { + text: string | Element | React.ReactNode; +} + +const styles = { + label: { + color: colors.grey500, + fontSize: 12, + pointerEvents: 'none', + textAlign: 'left', + transform: 'scale(0.75) translate(0px, -28px)', + transformOrigin: 'left top 0px', + transition: 'all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms', + userSelect: 'none', + width: 240, + zIndex: 1, + }, +}; + +export const InputLabel = (props: InputLabelProps) => { + return ( + + ); +}; diff --git a/packages/website/ts/components/ui/labeled_switcher.tsx b/packages/website/ts/components/ui/labeled_switcher.tsx new file mode 100644 index 000000000..3ed8ba0a4 --- /dev/null +++ b/packages/website/ts/components/ui/labeled_switcher.tsx @@ -0,0 +1,76 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; + +const CUSTOM_BLUE = '#63A6F1'; + +interface LabeledSwitcherProps { + labelLeft: string; + labelRight: string; + isLeftInitiallySelected: boolean; + onLeftLabelClickAsync: () => Promise; + onRightLabelClickAsync: () => Promise; +} + +interface LabeledSwitcherState { + isLeftSelected: boolean; +} + +export class LabeledSwitcher extends React.Component { + constructor(props: LabeledSwitcherProps) { + super(props); + this.state = { + isLeftSelected: props.isLeftInitiallySelected, + }; + } + public render() { + const isLeft = true; + return ( +
+ {this.renderLabel(this.props.labelLeft, isLeft, this.state.isLeftSelected)} + {this.renderLabel(this.props.labelRight, !isLeft, !this.state.isLeftSelected)} +
+ ); + } + private renderLabel(title: string, isLeft: boolean, isSelected: boolean) { + const borderStyle = `2px solid ${isSelected ? '#4F8BCF' : '#DADADA'}`; + const style = { + cursor: 'pointer', + backgroundColor: isSelected ? CUSTOM_BLUE : colors.grey200, + color: isSelected ? 'white' : '#A5A5A5', + boxShadow: isSelected ? `inset 0px 0px 4px #4083CE` : 'inset 0px 0px 4px #F7F6F6', + borderTop: borderStyle, + borderBottom: borderStyle, + [isLeft ? 'borderLeft' : 'borderRight']: borderStyle, + paddingTop: 12, + paddingBottom: 12, + }; + return ( +
+ {title} +
+ ); + } + private async onLabelClickAsync(isLeft: boolean): Promise { + this.setState({ + isLeftSelected: isLeft, + }); + let didSucceed; + if (isLeft) { + didSucceed = await this.props.onLeftLabelClickAsync(); + } else { + didSucceed = await this.props.onRightLabelClickAsync(); + } + if (!didSucceed) { + this.setState({ + isLeftSelected: !isLeft, + }); + } + } +} diff --git a/packages/website/ts/components/ui/lifecycle_raised_button.tsx b/packages/website/ts/components/ui/lifecycle_raised_button.tsx new file mode 100644 index 000000000..e93c80ba4 --- /dev/null +++ b/packages/website/ts/components/ui/lifecycle_raised_button.tsx @@ -0,0 +1,105 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {utils} from 'ts/utils/utils'; +import {Token} from 'ts/types'; +import {Blockchain} from 'ts/blockchain'; +import RaisedButton from 'material-ui/RaisedButton'; + +const COMPLETE_STATE_SHOW_LENGTH_MS = 2000; + +enum ButtonState { + READY, + LOADING, + COMPLETE, +}; + +interface LifeCycleRaisedButtonProps { + isHidden?: boolean; + isDisabled?: boolean; + isPrimary?: boolean; + labelReady: React.ReactNode|string; + labelLoading: React.ReactNode|string; + labelComplete: React.ReactNode|string; + onClickAsyncFn: () => boolean; + backgroundColor?: string; + labelColor?: string; +} + +interface LifeCycleRaisedButtonState { + buttonState: ButtonState; +} + +export class LifeCycleRaisedButton extends + React.Component { + public static defaultProps: Partial = { + isDisabled: false, + backgroundColor: 'white', + labelColor: 'rgb(97, 97, 97)', + }; + private buttonTimeoutId: number; + private didUnmount: boolean; + constructor(props: LifeCycleRaisedButtonProps) { + super(props); + this.state = { + buttonState: ButtonState.READY, + }; + } + public componentWillUnmount() { + clearTimeout(this.buttonTimeoutId); + this.didUnmount = true; + } + public render() { + if (this.props.isHidden === true) { + return ; + } + + let label; + switch (this.state.buttonState) { + case ButtonState.READY: + label = this.props.labelReady; + break; + case ButtonState.LOADING: + label = this.props.labelLoading; + break; + case ButtonState.COMPLETE: + label = this.props.labelComplete; + break; + default: + throw utils.spawnSwitchErr('ButtonState', this.state.buttonState); + } + return ( + + ); + } + public async onClickAsync() { + this.setState({ + buttonState: ButtonState.LOADING, + }); + const didSucceed = await this.props.onClickAsyncFn(); + if (this.didUnmount) { + return; // noop since unmount called before async callback returned. + } + if (didSucceed) { + this.setState({ + buttonState: ButtonState.COMPLETE, + }); + this.buttonTimeoutId = window.setTimeout(() => { + this.setState({ + buttonState: ButtonState.READY, + }); + }, COMPLETE_STATE_SHOW_LENGTH_MS); + } else { + this.setState({ + buttonState: ButtonState.READY, + }); + } + } +} diff --git a/packages/website/ts/components/ui/loading.tsx b/packages/website/ts/components/ui/loading.tsx new file mode 100644 index 000000000..39c119d8f --- /dev/null +++ b/packages/website/ts/components/ui/loading.tsx @@ -0,0 +1,36 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import Paper from 'material-ui/Paper'; +import {utils} from 'ts/utils/utils'; +import {DefaultPlayer as Video} from 'react-html5video'; +import 'react-html5video/dist/styles.css'; + +interface LoadingProps {} + +interface LoadingState {} + +export class Loading extends React.Component { + public render() { + return ( +
+ + {utils.isUserOnMobile() ? + : +
+ +
+ } +
Connecting to the blockchain...
+
+
+ ); + } +} diff --git a/packages/website/ts/components/ui/menu_item.tsx b/packages/website/ts/components/ui/menu_item.tsx new file mode 100644 index 000000000..b9caa91fb --- /dev/null +++ b/packages/website/ts/components/ui/menu_item.tsx @@ -0,0 +1,54 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Link} from 'react-router-dom'; +import {Styles} from 'ts/types'; +import {constants} from 'ts/utils/constants'; +import {colors} from 'material-ui/styles'; + +interface MenuItemProps { + to: string; + style?: React.CSSProperties; + onClick?: () => void; + className?: string; +} + +interface MenuItemState { + isHovering: boolean; +} + +export class MenuItem extends React.Component { + public static defaultProps: Partial = { + onClick: _.noop, + className: '', + }; + public constructor(props: MenuItemProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public render() { + const menuItemStyles = { + cursor: 'pointer', + opacity: this.state.isHovering ? 0.5 : 1, + }; + return ( + +
+ {this.props.children} +
+ + ); + } + private onToggleHover(isHovering: boolean) { + this.setState({ + isHovering, + }); + } +} diff --git a/packages/website/ts/components/ui/party.tsx b/packages/website/ts/components/ui/party.tsx new file mode 100644 index 000000000..b72e75181 --- /dev/null +++ b/packages/website/ts/components/ui/party.tsx @@ -0,0 +1,150 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import ReactTooltip = require('react-tooltip'); +import {colors} from 'material-ui/styles'; +import {Identicon} from 'ts/components/ui/identicon'; +import {EtherscanLinkSuffixes} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {EthereumAddress} from 'ts/components/ui/ethereum_address'; + +const MIN_ADDRESS_WIDTH = 60; +const IMAGE_DIMENSION = 100; +const IDENTICON_DIAMETER = 95; +const CHECK_MARK_GREEN = 'rgb(0, 195, 62)'; + +interface PartyProps { + label: string; + address: string; + networkId: number; + alternativeImage?: string; + identiconDiameter?: number; + identiconStyle?: React.CSSProperties; + isInTokenRegistry?: boolean; + hasUniqueNameAndSymbol?: boolean; +} + +interface PartyState {} + +export class Party extends React.Component { + public static defaultProps: Partial = { + identiconStyle: {}, + identiconDiameter: IDENTICON_DIAMETER, + }; + public render() { + const label = this.props.label; + const address = this.props.address; + const tooltipId = `${label}-${address}-tooltip`; + const identiconDiameter = this.props.identiconDiameter; + const addressWidth = identiconDiameter > MIN_ADDRESS_WIDTH ? + identiconDiameter : MIN_ADDRESS_WIDTH; + const emptyIdenticonStyles = { + width: identiconDiameter, + height: identiconDiameter, + backgroundColor: 'lightgray', + marginTop: 13, + marginBottom: 10, + }; + const tokenImageStyle = { + width: IMAGE_DIMENSION, + height: IMAGE_DIMENSION, + }; + const etherscanLinkIfExists = utils.getEtherScanLinkIfExists( + this.props.address, this.props.networkId, EtherscanLinkSuffixes.address, + ); + const isRegistered = this.props.isInTokenRegistry; + const registeredTooltipId = `${this.props.address}-${isRegistered}-registeredTooltip`; + const uniqueNameAndSymbolTooltipId = `${this.props.address}-${isRegistered}-uniqueTooltip`; + return ( +
+
{label}
+ {_.isEmpty(address) ? +
: + + {isRegistered && !_.isUndefined(this.props.alternativeImage) ? + : +
+ +
+ } +
+ } +
+
+ +
+ {!_.isUndefined(this.props.isInTokenRegistry) && +
+
+ + + {' '} + {isRegistered ? 'Registered' : 'Unregistered'} token + + {isRegistered ? +
+ This token address was found in the token registry
+ smart contract and is therefore believed to be a
+ legitimate token. +
: +
+ This token is not included in the token registry
+ smart contract. We cannot guarantee the legitimacy
+ of this token. Make sure to verify its address on Etherscan. +
+ } +
+
+
+ } + {!_.isUndefined(this.props.hasUniqueNameAndSymbol) && !this.props.hasUniqueNameAndSymbol && +
+
+ + + {' '} + Suspicious token + + This token shares it's name, symbol or both with
+ a token in the 0x Token Registry but it has a different
+ smart contract address. This is most likely a scam token! +
+
+
+ } +
+
+ ); + } +} diff --git a/packages/website/ts/components/ui/required_label.tsx b/packages/website/ts/components/ui/required_label.tsx new file mode 100644 index 000000000..f9c73157a --- /dev/null +++ b/packages/website/ts/components/ui/required_label.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; + +export interface RequiredLabelProps { + label: string|React.ReactNode; +} + +export const RequiredLabel = (props: RequiredLabelProps) => { + return ( + + {props.label} + * + + ); +}; diff --git a/packages/website/ts/components/ui/simple_loading.tsx b/packages/website/ts/components/ui/simple_loading.tsx new file mode 100644 index 000000000..12d09ecc4 --- /dev/null +++ b/packages/website/ts/components/ui/simple_loading.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import CircularProgress from 'material-ui/CircularProgress'; + +export interface SimpleLoadingProps { + message: string; +} + +export const SimpleLoading = (props: SimpleLoadingProps) => { + return ( +
+
+ +
+ {props.message} +
+
+
+ ); +}; diff --git a/packages/website/ts/components/ui/swap_icon.tsx b/packages/website/ts/components/ui/swap_icon.tsx new file mode 100644 index 000000000..89bb33d55 --- /dev/null +++ b/packages/website/ts/components/ui/swap_icon.tsx @@ -0,0 +1,46 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {constants} from 'ts/utils/constants'; +import {colors} from 'material-ui/styles'; + +interface SwapIconProps { + swapTokensFn: () => void; +} + +interface SwapIconState { + isHovering: boolean; +} + +export class SwapIcon extends React.Component { + public constructor(props: SwapIconProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public render() { + const swapStyles = { + color: this.state.isHovering ? colors.amber600 : colors.amber800, + fontSize: 50, + }; + return ( +
+ +
+ ); + } + private onToggleHover(isHovering: boolean) { + this.setState({ + isHovering, + }); + } +} diff --git a/packages/website/ts/components/ui/token_icon.tsx b/packages/website/ts/components/ui/token_icon.tsx new file mode 100644 index 000000000..168c09bd4 --- /dev/null +++ b/packages/website/ts/components/ui/token_icon.tsx @@ -0,0 +1,29 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Token} from 'ts/types'; +import {Identicon} from 'ts/components/ui/identicon'; + +interface TokenIconProps { + token: Token; + diameter: number; +} + +interface TokenIconState {} + +export class TokenIcon extends React.Component { + public render() { + const token = this.props.token; + const diameter = this.props.diameter; + return ( +
+ {(token.isRegistered && !_.isUndefined(token.iconUrl)) ? + : + + } +
+ ); + } +} diff --git a/packages/website/ts/components/visual_order.tsx b/packages/website/ts/components/visual_order.tsx new file mode 100644 index 000000000..a7d6d1a9e --- /dev/null +++ b/packages/website/ts/components/visual_order.tsx @@ -0,0 +1,77 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {ZeroEx} from '0x.js'; +import {AssetToken, Token, TokenByAddress} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {Party} from 'ts/components/ui/party'; +import {constants} from 'ts/utils/constants'; + +const PRECISION = 5; + +interface VisualOrderProps { + orderTakerAddress: string; + orderMakerAddress: string; + makerAssetToken: AssetToken; + takerAssetToken: AssetToken; + makerToken: Token; + takerToken: Token; + networkId: number; + tokenByAddress: TokenByAddress; + isMakerTokenAddressInRegistry: boolean; + isTakerTokenAddressInRegistry: boolean; +} + +interface VisualOrderState {} + +export class VisualOrder extends React.Component { + public render() { + const allTokens = _.values(this.props.tokenByAddress); + const makerImage = this.props.makerToken.iconUrl; + const takerImage = this.props.takerToken.iconUrl; + return ( +
+
+
+ +
+
+
+ {this.renderAmount(this.props.takerAssetToken, this.props.takerToken)} +
+
+ +
+
+ {this.renderAmount(this.props.makerAssetToken, this.props.makerToken)} +
+
+
+ +
+
+
+ ); + } + private renderAmount(assetToken: AssetToken, token: Token) { + const unitAmount = ZeroEx.toUnitAmount(assetToken.amount, token.decimals); + return ( +
+ {unitAmount.toNumber().toFixed(PRECISION)} {token.symbol} +
+ ); + } +} -- cgit v1.2.3 From a42e8ae873d075c9013ed71d261c1c4f33715a00 Mon Sep 17 00:00:00 2001 From: Fabio Berger Date: Thu, 23 Nov 2017 13:56:34 -0600 Subject: Add missing keys --- packages/website/ts/components/token_balances.tsx | 2 ++ 1 file changed, 2 insertions(+) (limited to 'packages/website/ts/components') diff --git a/packages/website/ts/components/token_balances.tsx b/packages/website/ts/components/token_balances.tsx index c552d19dc..2d533a9f2 100644 --- a/packages/website/ts/components/token_balances.tsx +++ b/packages/website/ts/components/token_balances.tsx @@ -132,6 +132,7 @@ export class TokenBalances extends React.Component Date: Thu, 23 Nov 2017 13:57:01 -0600 Subject: Add error when unexpected condition hit --- packages/website/ts/components/trade_history/trade_history_item.tsx | 3 +++ 1 file changed, 3 insertions(+) (limited to 'packages/website/ts/components') diff --git a/packages/website/ts/components/trade_history/trade_history_item.tsx b/packages/website/ts/components/trade_history/trade_history_item.tsx index 96b755e3c..58bdf84ae 100644 --- a/packages/website/ts/components/trade_history/trade_history_item.tsx +++ b/packages/website/ts/components/trade_history/trade_history_item.tsx @@ -122,6 +122,9 @@ export class TradeHistoryItem extends React.Component Date: Thu, 23 Nov 2017 13:58:27 -0600 Subject: update tokenByAddress after tokenStateByAddress to avoid race-condition --- packages/website/ts/components/generate_order/asset_picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/website/ts/components') diff --git a/packages/website/ts/components/generate_order/asset_picker.tsx b/packages/website/ts/components/generate_order/asset_picker.tsx index 59826d06e..410b0440a 100644 --- a/packages/website/ts/components/generate_order/asset_picker.tsx +++ b/packages/website/ts/components/generate_order/asset_picker.tsx @@ -269,7 +269,6 @@ export class AssetPicker extends React.Component