aboutsummaryrefslogtreecommitdiffstats
path: root/packages/website/ts/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/website/ts/components')
-rw-r--r--packages/website/ts/components/dialogs/blockchain_err_dialog.tsx158
-rw-r--r--packages/website/ts/components/dialogs/eth_weth_conversion_dialog.tsx139
-rw-r--r--packages/website/ts/components/dialogs/ledger_config_dialog.tsx288
-rw-r--r--packages/website/ts/components/dialogs/portal_disclaimer_dialog.tsx44
-rw-r--r--packages/website/ts/components/dialogs/send_dialog.tsx126
-rw-r--r--packages/website/ts/components/dialogs/track_token_confirmation_dialog.tsx99
-rw-r--r--packages/website/ts/components/dialogs/u2f_not_supported_dialog.tsx53
-rw-r--r--packages/website/ts/components/eth_weth_conversion_button.tsx101
-rw-r--r--packages/website/ts/components/fill_order.tsx714
-rw-r--r--packages/website/ts/components/fill_order_json.tsx69
-rw-r--r--packages/website/ts/components/fill_warning_dialog.tsx47
-rw-r--r--packages/website/ts/components/flash_messages/token_send_completed.tsx38
-rw-r--r--packages/website/ts/components/flash_messages/transaction_submitted.tsx29
-rw-r--r--packages/website/ts/components/footer.tsx255
-rw-r--r--packages/website/ts/components/generate_order/asset_picker.tsx291
-rw-r--r--packages/website/ts/components/generate_order/generate_order_form.tsx348
-rw-r--r--packages/website/ts/components/generate_order/new_token_form.tsx237
-rw-r--r--packages/website/ts/components/inputs/address_input.tsx74
-rw-r--r--packages/website/ts/components/inputs/allowance_toggle.tsx94
-rw-r--r--packages/website/ts/components/inputs/balance_bounded_input.tsx160
-rw-r--r--packages/website/ts/components/inputs/eth_amount_input.tsx51
-rw-r--r--packages/website/ts/components/inputs/expiration_input.tsx108
-rw-r--r--packages/website/ts/components/inputs/hash_input.tsx65
-rw-r--r--packages/website/ts/components/inputs/identicon_address_input.tsx56
-rw-r--r--packages/website/ts/components/inputs/token_amount_input.tsx69
-rw-r--r--packages/website/ts/components/inputs/token_input.tsx107
-rw-r--r--packages/website/ts/components/order_json.tsx164
-rw-r--r--packages/website/ts/components/portal.tsx344
-rw-r--r--packages/website/ts/components/portal_menu.tsx68
-rw-r--r--packages/website/ts/components/send_button.tsx89
-rw-r--r--packages/website/ts/components/token_balances.tsx697
-rw-r--r--packages/website/ts/components/top_bar.tsx370
-rw-r--r--packages/website/ts/components/top_bar_menu_item.tsx53
-rw-r--r--packages/website/ts/components/track_token_confirmation.tsx65
-rw-r--r--packages/website/ts/components/trade_history/trade_history.tsx115
-rw-r--r--packages/website/ts/components/trade_history/trade_history_item.tsx178
-rw-r--r--packages/website/ts/components/ui/alert.tsx27
-rw-r--r--packages/website/ts/components/ui/badge.tsx58
-rw-r--r--packages/website/ts/components/ui/copy_icon.tsx81
-rw-r--r--packages/website/ts/components/ui/drop_down_menu_item.tsx117
-rw-r--r--packages/website/ts/components/ui/ethereum_address.tsx35
-rw-r--r--packages/website/ts/components/ui/etherscan_icon.tsx50
-rw-r--r--packages/website/ts/components/ui/fake_text_field.tsx35
-rw-r--r--packages/website/ts/components/ui/flash_message.tsx40
-rw-r--r--packages/website/ts/components/ui/help_tooltip.tsx22
-rw-r--r--packages/website/ts/components/ui/identicon.tsx36
-rw-r--r--packages/website/ts/components/ui/input_label.tsx27
-rw-r--r--packages/website/ts/components/ui/labeled_switcher.tsx76
-rw-r--r--packages/website/ts/components/ui/lifecycle_raised_button.tsx105
-rw-r--r--packages/website/ts/components/ui/loading.tsx36
-rw-r--r--packages/website/ts/components/ui/menu_item.tsx54
-rw-r--r--packages/website/ts/components/ui/party.tsx150
-rw-r--r--packages/website/ts/components/ui/required_label.tsx15
-rw-r--r--packages/website/ts/components/ui/simple_loading.tsx23
-rw-r--r--packages/website/ts/components/ui/swap_icon.tsx46
-rw-r--r--packages/website/ts/components/ui/token_icon.tsx29
-rw-r--r--packages/website/ts/components/visual_order.tsx77
57 files changed, 7102 insertions, 0 deletions
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<BlockchainErrDialogProps, undefined> {
+ public render() {
+ const dialogActions = [
+ <FlatButton
+ label="Ok"
+ primary={true}
+ onTouchTap={this.props.toggleDialogFn.bind(this.props.toggleDialogFn, false)}
+ />,
+ ];
+
+ const hasWalletAddress = this.props.userAddress !== '';
+ return (
+ <Dialog
+ title={this.getTitle(hasWalletAddress)}
+ titleStyle={{fontWeight: 100}}
+ actions={dialogActions}
+ open={this.props.isOpen}
+ contentStyle={{width: 400}}
+ onRequestClose={this.props.toggleDialogFn.bind(this.props.toggleDialogFn, false)}
+ autoScrollBodyContent={true}
+ >
+ <div className="pt2" style={{color: colors.grey700}}>
+ {this.renderExplanation(hasWalletAddress)}
+ </div>
+ </Dialog>
+ );
+ }
+ 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 (
+ <div>
+ You were disconnected from the backing Ethereum node.
+ {' '}If using <a href={constants.METAMASK_CHROME_STORE_URL} target="_blank">
+ Metamask
+ </a> or <a href={constants.MIST_DOWNLOAD_URL} target="_blank">Mist</a> try refreshing
+ {' '}the page. If using a locally hosted Ethereum node, make sure it's still running.
+ </div>
+ );
+ }
+ private renderUnexpectedErrorExplanation() {
+ return (
+ <div>
+ We encountered an unexpected error. Please try refreshing the page.
+ </div>
+ );
+ }
+ private renderNoWalletFoundExplanation() {
+ return (
+ <div>
+ <div>
+ 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:
+ </div>
+ <h4>1. Metamask chrome extension</h4>
+ <div>
+ You can install the{' '}
+ <a href={constants.METAMASK_CHROME_STORE_URL} target="_blank">
+ Metamask
+ </a> Chrome extension Ethereum wallet. Once installed and set up, refresh this page.
+ <div className="pt1">
+ <span className="bold">Note:</span>
+ {' '}If you already have Metamask installed, make sure it is unlocked.
+ </div>
+ </div>
+ <h4>Parity Signer</h4>
+ <div>
+ The <a href={constants.PARITY_CHROME_STORE_URL} target="_blank">Parity Signer
+ Chrome extension</a>{' '}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.'}
+ </div>
+ <div className="pt2">
+ <span className="bold">Note:</span>
+ {' '}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.
+ </div>
+ </div>
+ );
+ }
+ private renderContractsNotDeployedExplanation() {
+ return (
+ <div>
+ <div>
+ 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}).` :
+ `.`
+ }
+ </div>
+ <h4>Metamask</h4>
+ <div>
+ If you are using{' '}
+ <a href={constants.METAMASK_CHROME_STORE_URL} target="_blank">
+ Metamask
+ </a>, you can switch networks in the top left corner of the extension popover.
+ </div>
+ <h4>Parity Signer</h4>
+ <div>
+ If using the <a href={constants.PARITY_CHROME_STORE_URL} target="_blank">Parity Signer
+ Chrome extension</a>, 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.'
+ }
+ </div>
+ </div>
+ );
+ }
+}
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<EthWethConversionDialogProps, EthWethConversionDialogState> {
+ constructor() {
+ super();
+ this.state = {
+ direction: Side.deposit,
+ shouldShowIncompleteErrs: false,
+ hasErrors: true,
+ };
+ }
+ public render() {
+ const convertDialogActions = [
+ <FlatButton
+ key="cancel"
+ label="Cancel"
+ onTouchTap={this.onCancel.bind(this)}
+ />,
+ <FlatButton
+ key="convert"
+ label="Convert"
+ primary={true}
+ onTouchTap={this.onConvertClick.bind(this)}
+ />,
+ ];
+ return (
+ <Dialog
+ title="I want to convert"
+ titleStyle={{fontWeight: 100}}
+ actions={convertDialogActions}
+ open={this.props.isOpen}
+ >
+ {this.renderConversionDialogBody()}
+ </Dialog>
+ );
+ }
+ private renderConversionDialogBody() {
+ return (
+ <div className="mx-auto" style={{maxWidth: 300}}>
+ <RadioButtonGroup
+ className="pb1"
+ defaultSelected={this.state.direction}
+ name="conversionDirection"
+ onChange={this.onConversionDirectionChange.bind(this)}
+ >
+ <RadioButton
+ className="pb1"
+ value={Side.deposit}
+ label="Ether -> Ether Tokens"
+ />
+ <RadioButton
+ value={Side.receive}
+ label="Ether Tokens -> Ether"
+ />
+ </RadioButtonGroup>
+ {this.state.direction === Side.receive ?
+ <TokenAmountInput
+ label="Amount to convert"
+ token={this.props.token}
+ tokenState={this.props.tokenState}
+ shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs}
+ shouldCheckBalance={true}
+ shouldCheckAllowance={false}
+ onChange={this.onValueChange.bind(this)}
+ amount={this.state.value}
+ onVisitBalancesPageClick={this.props.onCancelled}
+ /> :
+ <EthAmountInput
+ label="Amount to convert"
+ balance={this.props.etherBalance}
+ amount={this.state.value}
+ onChange={this.onValueChange.bind(this)}
+ shouldCheckBalance={true}
+ shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs}
+ onVisitBalancesPageClick={this.props.onCancelled}
+ />
+ }
+ </div>
+ );
+ }
+ 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<LedgerConfigDialogProps, LedgerConfigDialogState> {
+ constructor(props: LedgerConfigDialogProps) {
+ super(props);
+ this.state = {
+ didConnectFail: false,
+ stepIndex: LedgerSteps.CONNECT,
+ userAddresses: [],
+ addressBalances: [],
+ derivationPath: constants.DEFAULT_DERIVATION_PATH,
+ derivationErrMsg: '',
+ };
+ }
+ public render() {
+ const dialogActions = [
+ <FlatButton
+ label="Cancel"
+ onTouchTap={this.onClose.bind(this)}
+ />,
+ ];
+ const dialogTitle = this.state.stepIndex === LedgerSteps.CONNECT ?
+ 'Connect to your Ledger' :
+ 'Select desired address';
+ return (
+ <Dialog
+ title={dialogTitle}
+ titleStyle={{fontWeight: 100}}
+ actions={dialogActions}
+ open={this.props.isOpen}
+ onRequestClose={this.onClose.bind(this)}
+ autoScrollBodyContent={true}
+ bodyStyle={{paddingBottom: 0}}
+ >
+ <div style={{color: colors.grey700, paddingTop: 1}}>
+ {this.state.stepIndex === LedgerSteps.CONNECT &&
+ this.renderConnectStep()
+ }
+ {this.state.stepIndex === LedgerSteps.SELECT_ADDRESS &&
+ this.renderSelectAddressStep()
+ }
+ </div>
+ </Dialog>
+ );
+ }
+ private renderConnectStep() {
+ return (
+ <div>
+ <div className="h4 pt3">
+ Follow these instructions before proceeding:
+ </div>
+ <ol>
+ <li className="pb1">
+ Connect your Ledger Nano S & Open the Ethereum application
+ </li>
+ <li className="pb1">
+ Verify that Browser Support is enabled in Settings
+ </li>
+ <li className="pb1">
+ If no Browser Support is found in settings, verify that you have{' '}
+ <a href="https://www.ledgerwallet.com/apps/manager" target="_blank">Firmware >1.2</a>
+ </li>
+ </ol>
+ <div className="center pb3">
+ <LifeCycleRaisedButton
+ isPrimary={true}
+ labelReady="Connect to Ledger"
+ labelLoading="Connecting..."
+ labelComplete="Connected!"
+ onClickAsyncFn={this.onConnectLedgerClickAsync.bind(this, true)}
+ />
+ {this.state.didConnectFail &&
+ <div className="pt2 left-align" style={{color: colors.red200}}>
+ Failed to connect. Follow the instructions and try again.
+ </div>
+ }
+ </div>
+ </div>
+ );
+ }
+ private renderSelectAddressStep() {
+ return (
+ <div>
+ <div>
+ <Table
+ bodyStyle={{height: 300}}
+ onRowSelection={this.onAddressSelected.bind(this)}
+ >
+ <TableHeader displaySelectAll={false}>
+ <TableRow>
+ <TableHeaderColumn colSpan={2}>Address</TableHeaderColumn>
+ <TableHeaderColumn>Balance</TableHeaderColumn>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {this.renderAddressTableRows()}
+ </TableBody>
+ </Table>
+ </div>
+ <div className="flex pt2" style={{height: 100}}>
+ <div className="overflow-hidden" style={{width: 180}}>
+ <TextField
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500}}
+ floatingLabelText="Update path derivation (advanced)"
+ value={this.state.derivationPath}
+ errorText={this.state.derivationErrMsg}
+ onChange={this.onDerivationPathChanged.bind(this)}
+ />
+ </div>
+ <div className="pl2" style={{paddingTop: 28}}>
+ <LifeCycleRaisedButton
+ labelReady="Update"
+ labelLoading="Updating..."
+ labelComplete="Updated!"
+ onClickAsyncFn={this.onFetchAddressesForDerivationPathAsync.bind(this, true)}
+ />
+ </div>
+ </div>
+ </div>
+ );
+ }
+ 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 (
+ <TableRow key={userAddress} style={{height: 40}}>
+ <TableRowColumn colSpan={2}>
+ <div
+ data-tip={true}
+ data-for={addressTooltipId}
+ >
+ {userAddress}
+ </div>
+ <ReactTooltip id={addressTooltipId}>{userAddress}</ReactTooltip>
+ </TableRowColumn>
+ <TableRowColumn>
+ <div
+ data-tip={true}
+ data-for={balanceTooltipId}
+ >
+ {balanceString}
+ </div>
+ <ReactTooltip id={balanceTooltipId}>{balanceString}</ReactTooltip>
+ </TableRowColumn>
+ </TableRow>
+ );
+ });
+ 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<string[]> {
+ 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 (
+ <Dialog
+ title="0x Portal Disclaimer"
+ titleStyle={{fontWeight: 100}}
+ actions={[
+ <FlatButton
+ label="I Agree"
+ onTouchTap={props.onToggleDialog.bind(this)}
+ />,
+ ]}
+ open={props.isOpen}
+ onRequestClose={props.onToggleDialog.bind(this)}
+ autoScrollBodyContent={true}
+ modal={true}
+ >
+ <div className="pt2" style={{color: colors.grey700}}>
+ <div>
+ 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.
+ </div>
+ </div>
+ </Dialog>
+ );
+}
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<SendDialogProps, SendDialogState> {
+ constructor() {
+ super();
+ this.state = {
+ recipient: '',
+ shouldShowIncompleteErrs: false,
+ isAmountValid: false,
+ };
+ }
+ public render() {
+ const transferDialogActions = [
+ <FlatButton
+ key="cancelTransfer"
+ label="Cancel"
+ onTouchTap={this.onCancel.bind(this)}
+ />,
+ <FlatButton
+ key="sendTransfer"
+ disabled={this.hasErrors()}
+ label="Send"
+ primary={true}
+ onTouchTap={this.onSendClick.bind(this)}
+ />,
+ ];
+ return (
+ <Dialog
+ title="I want to send"
+ titleStyle={{fontWeight: 100}}
+ actions={transferDialogActions}
+ open={this.props.isOpen}
+ >
+ {this.renderSendDialogBody()}
+ </Dialog>
+ );
+ }
+ private renderSendDialogBody() {
+ return (
+ <div className="mx-auto" style={{maxWidth: 300}}>
+ <div style={{height: 80}}>
+ <AddressInput
+ initialAddress={this.state.recipient}
+ updateAddress={this.onRecipientChange.bind(this)}
+ isRequired={true}
+ label={'Recipient address'}
+ hintText={'Address'}
+ />
+ </div>
+ <TokenAmountInput
+ label="Amount to send"
+ token={this.props.token}
+ tokenState={this.props.tokenState}
+ shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs}
+ shouldCheckBalance={true}
+ shouldCheckAllowance={false}
+ onChange={this.onValueChange.bind(this)}
+ amount={this.state.value}
+ onVisitBalancesPageClick={this.props.onCancelled}
+ />
+ </div>
+ );
+ }
+ 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<TrackTokenConfirmationDialogProps, TrackTokenConfirmationDialogState> {
+ constructor(props: TrackTokenConfirmationDialogProps) {
+ super(props);
+ this.state = {
+ isAddingTokenToTracked: false,
+ };
+ }
+ public render() {
+ const tokens = this.props.tokens;
+ return (
+ <Dialog
+ title="Tracking confirmation"
+ titleStyle={{fontWeight: 100}}
+ actions={[
+ <FlatButton
+ label="No"
+ onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, false)}
+ />,
+ <FlatButton
+ label="Yes"
+ onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, true)}
+ />,
+ ]}
+ open={this.props.isOpen}
+ onRequestClose={this.props.onToggleDialog.bind(this, false)}
+ autoScrollBodyContent={true}
+ >
+ <div className="pt2">
+ <TrackTokenConfirmation
+ tokens={tokens}
+ networkId={this.props.networkId}
+ tokenByAddress={this.props.tokenByAddress}
+ isAddingTokenToTracked={this.state.isAddingTokenToTracked}
+ />
+ </div>
+ </Dialog>
+ );
+ }
+ 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 (
+ <Dialog
+ title="U2F Not Supported"
+ titleStyle={{fontWeight: 100}}
+ actions={[
+ <FlatButton
+ label="Ok"
+ onTouchTap={props.onToggleDialog.bind(this)}
+ />,
+ ]}
+ open={props.isOpen}
+ onRequestClose={props.onToggleDialog.bind(this)}
+ autoScrollBodyContent={true}
+ >
+ <div className="pt2" style={{color: colors.grey700}}>
+ <div>
+ 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.
+ </div>
+ <div>
+ <ul>
+ <li className="pb1">Chrome version 38 or later</li>
+ <li className="pb1">Opera version 40 of later</li>
+ <li>
+ Firefox with{' '}
+ <a
+ href={constants.FIREFOX_U2F_ADDON}
+ target="_blank"
+ style={{textDecoration: 'underline'}}
+ >
+ this extension
+ </a>.
+ </li>
+ </ul>
+ </div>
+ </div>
+ </Dialog>
+ );
+}
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<EthWethConversionButtonProps, EthWethConversionButtonState> {
+ public constructor(props: EthWethConversionButtonProps) {
+ super(props);
+ this.state = {
+ isEthConversionDialogVisible: false,
+ isEthConversionHappening: false,
+ };
+ }
+ public render() {
+ const labelStyle = this.state.isEthConversionHappening ? {fontSize: 10} : {};
+ return (
+ <div>
+ <RaisedButton
+ style={{width: '100%'}}
+ labelStyle={labelStyle}
+ disabled={this.state.isEthConversionHappening}
+ label={this.state.isEthConversionHappening ? 'Converting...' : 'Convert'}
+ onClick={this.toggleConversionDialog.bind(this)}
+ />
+ <EthWethConversionDialog
+ isOpen={this.state.isEthConversionDialogVisible}
+ onComplete={this.onConversionAmountSelectedAsync.bind(this)}
+ onCancelled={this.toggleConversionDialog.bind(this)}
+ etherBalance={this.props.userEtherBalance}
+ token={this.props.ethToken}
+ tokenState={this.props.ethTokenState}
+ />
+ </div>
+ );
+ }
+ 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<FillOrderProps, FillOrderState> {
+ 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 (
+ <div className="clearfix lg-px4 md-px4 sm-px2" style={{minHeight: 600}}>
+ <h3>Fill an order</h3>
+ <Divider />
+ <div>
+ {!this.props.isOrderInUrl &&
+ <div>
+ <div className="pt2 pb2">
+ Paste an order JSON snippet below to begin
+ </div>
+ <div className="pb2">Order JSON</div>
+ <FillOrderJSON
+ blockchain={this.props.blockchain}
+ tokenByAddress={this.props.tokenByAddress}
+ networkId={this.props.networkId}
+ orderJSON={this.state.orderJSON}
+ onFillOrderJSONChanged={this.onFillOrderJSONChanged.bind(this)}
+ />
+ {this.renderOrderJsonNotices()}
+ </div>
+ }
+ <div>
+ {!_.isUndefined(this.state.parsedOrder) && this.state.didOrderValidationRun
+ && this.state.areAllInvolvedTokensTracked &&
+ this.renderVisualOrder()
+ }
+ </div>
+ {this.props.isOrderInUrl &&
+ <div className="pt2">
+ <Card style={{boxShadow: 'none', backgroundColor: 'none', border: '1px solid #eceaea'}}>
+ <CardHeader
+ title="Order JSON"
+ actAsExpander={true}
+ showExpandableButton={true}
+ />
+ <CardText expandable={true}>
+ <FillOrderJSON
+ blockchain={this.props.blockchain}
+ tokenByAddress={this.props.tokenByAddress}
+ networkId={this.props.networkId}
+ orderJSON={this.state.orderJSON}
+ onFillOrderJSONChanged={this.onFillOrderJSONChanged.bind(this)}
+ />
+ </CardText>
+ </Card>
+ {this.renderOrderJsonNotices()}
+ </div>
+ }
+ </div>
+ <FillWarningDialog
+ isOpen={this.state.isFillWarningDialogOpen}
+ onToggleDialog={this.onFillWarningClosed.bind(this)}
+ />
+ <TrackTokenConfirmationDialog
+ userAddress={this.props.userAddress}
+ networkId={this.props.networkId}
+ blockchain={this.props.blockchain}
+ tokenByAddress={this.props.tokenByAddress}
+ dispatcher={this.props.dispatcher}
+ tokens={this.state.tokensToTrack}
+ isOpen={this.state.isConfirmingTokenTracking}
+ onToggleDialog={this.onToggleTrackConfirmDialog.bind(this)}
+ />
+ </div>
+ );
+ }
+ private renderOrderJsonNotices() {
+ return (
+ <div>
+ {!_.isUndefined(this.props.initialOrder) && !this.state.didOrderValidationRun &&
+ <div className="pt2">
+ <span className="pr1">
+ <i className="zmdi zmdi-spinner zmdi-hc-spin" />
+ </span>
+ <span>Validating order...</span>
+ </div>
+ }
+ {!_.isEmpty(this.state.orderJSONErrMsg) &&
+ <Alert type={AlertTypes.ERROR} message={this.state.orderJSONErrMsg} />
+ }
+ </div>
+ );
+ }
+ 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 (
+ <div className="pt3 pb1">
+ <div className="clearfix pb2" style={{width: '100%'}}>
+ <div className="inline left">Order details</div>
+ <div className="inline right" style={{minWidth: 208}}>
+ <div className="col col-4 pl2" style={{color: '#BEBEBE'}}>
+ Maker:
+ </div>
+ <div className="col col-2 pr1">
+ <Identicon
+ address={this.state.parsedOrder.maker.address}
+ diameter={23}
+ />
+ </div>
+ <div className="col col-6">
+ <EthereumAddress
+ address={this.state.parsedOrder.maker.address}
+ networkId={this.props.networkId}
+ />
+ </div>
+ </div>
+ </div>
+ <div className="lg-px4 md-px4 sm-px0">
+ <div className="lg-px4 md-px4 sm-px1 pt1">
+ <VisualOrder
+ orderTakerAddress={orderTaker}
+ orderMakerAddress={this.state.parsedOrder.maker.address}
+ makerAssetToken={makerAssetToken}
+ takerAssetToken={takerAssetToken}
+ tokenByAddress={this.props.tokenByAddress}
+ makerToken={makerToken}
+ takerToken={takerToken}
+ networkId={this.props.networkId}
+ isMakerTokenAddressInRegistry={this.state.isMakerTokenAddressInRegistry}
+ isTakerTokenAddressInRegistry={this.state.isTakerTokenAddressInRegistry}
+ />
+ <div className="center pt3 pb2">
+ Expires: {expiryDate} UTC
+ </div>
+ </div>
+ </div>
+ {!isUserMaker &&
+ <div className="clearfix mx-auto" style={{width: 315, height: 108}}>
+ <div className="col col-7" style={{maxWidth: 235}}>
+ <TokenAmountInput
+ label="Fill amount"
+ onChange={this.onFillAmountChange.bind(this)}
+ shouldShowIncompleteErrs={false}
+ token={fillToken}
+ tokenState={fillTokenState}
+ amount={fillAssetToken.amount}
+ shouldCheckBalance={true}
+ shouldCheckAllowance={true}
+ />
+ </div>
+ <div
+ className="col col-5 pl1"
+ style={{color: CUSTOM_LIGHT_GRAY, paddingTop: 39}}
+ >
+ = {accounting.formatNumber(orderReceiveAmount, 6)} {makerToken.symbol}
+ </div>
+ </div>
+ }
+ <div>
+ {isUserMaker ?
+ <div>
+ <RaisedButton
+ style={{width: '100%'}}
+ disabled={this.state.isCancelling}
+ label={this.state.isCancelling ? 'Cancelling order...' : 'Cancel order'}
+ onClick={this.onCancelOrderClickFireAndForgetAsync.bind(this)}
+ />
+ {this.state.didCancelOrderSucceed &&
+ <Alert
+ type={AlertTypes.SUCCESS}
+ message={this.renderCancelSuccessMsg()}
+ />
+ }
+ </div> :
+ <div>
+ <RaisedButton
+ style={{width: '100%'}}
+ disabled={this.state.isFilling}
+ label={this.state.isFilling ? 'Filling order...' : 'Fill order'}
+ onClick={this.onFillOrderClick.bind(this)}
+ />
+ {!_.isEmpty(this.state.globalErrMsg) &&
+ <Alert type={AlertTypes.ERROR} message={this.state.globalErrMsg} />
+ }
+ {this.state.didFillOrderSucceed &&
+ <Alert
+ type={AlertTypes.SUCCESS}
+ message={this.renderFillSuccessMsg()}
+ />
+ }
+ </div>
+ }
+ </div>
+ </div>
+ );
+ }
+ private renderFillSuccessMsg() {
+ return (
+ <div>
+ Order successfully filled. See the trade details in your{' '}
+ <Link
+ to={`${WebsitePaths.Portal}/trades`}
+ style={{color: 'white'}}
+ >
+ trade history
+ </Link>
+ </div>
+ );
+ }
+ private renderCancelSuccessMsg() {
+ return (
+ <div>
+ Order successfully cancelled.
+ </div>
+ );
+ }
+ 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<void> {
+ 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<void> {
+ 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<FillOrderJSONProps, FillOrderJSONState> {
+ 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 (
+ <div>
+ <Paper className="p1 overflow-hidden" style={{height: 164}}>
+ <TextField
+ id="orderJSON"
+ hintStyle={{bottom: 0, top: 0}}
+ fullWidth={true}
+ value={this.props.orderJSON}
+ onChange={this.props.onFillOrderJSONChanged.bind(this)}
+ hintText={hintOrderJSON}
+ multiLine={true}
+ rows={6}
+ rowsMax={6}
+ underlineStyle={{display: 'none'}}
+ textareaStyle={{marginTop: 0}}
+ />
+ </Paper>
+ </div>
+ );
+ }
+}
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 (
+ <Dialog
+ title="Warning"
+ titleStyle={{fontWeight: 100, color: colors.red500}}
+ actions={[
+ <FlatButton
+ label="Cancel"
+ onTouchTap={props.onToggleDialog.bind(this, didCancel)}
+ />,
+ <FlatButton
+ label="Fill Order"
+ onTouchTap={props.onToggleDialog.bind(this, !didCancel)}
+ />,
+ ]}
+ open={props.isOpen}
+ onRequestClose={props.onToggleDialog.bind(this)}
+ autoScrollBodyContent={true}
+ modal={true}
+ >
+ <div className="pt2" style={{color: colors.grey700}}>
+ <div>
+ 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 (
+ <a
+ href="https://0xproject.com/wiki#Verifying-Custom-Tokens"
+ target="_blank"
+ >
+ See this how-to guide
+ </a>) before filling an order. <b>This action may result in the loss of funds</b>.
+ </div>
+ </div>
+ </Dialog>
+ );
+}
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<TokenSendCompletedProps, TokenSendCompletedState> {
+ public render() {
+ const etherScanLink = !_.isUndefined(this.props.etherScanLinkIfExists) &&
+ (
+ <a
+ style={{color: 'white'}}
+ href={`${this.props.etherScanLinkIfExists}`}
+ target="_blank"
+ >
+ Verify on Etherscan
+ </a>
+ );
+ const amountInUnits = ZeroEx.toUnitAmount(this.props.amountInBaseUnits, this.props.token.decimals);
+ const truncatedAddress = utils.getAddressBeginAndEnd(this.props.toAddress);
+ return (
+ <div>
+ {`Sent ${amountInUnits} ${this.props.token.symbol} to ${truncatedAddress}: `}
+ {etherScanLink}
+ </div>
+ );
+ }
+}
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<TransactionSubmittedProps, TransactionSubmittedState> {
+ public render() {
+ if (_.isUndefined(this.props.etherScanLinkIfExists)) {
+ return <div>Transaction submitted to the network</div>;
+ } else {
+ return (
+ <div>
+ Transaction submitted to the network:{' '}
+ <a
+ style={{color: 'white'}}
+ href={`${this.props.etherScanLinkIfExists}`}
+ target="_blank"
+ >
+ Verify on Etherscan
+ </a>
+ </div>
+ );
+ }
+ }
+}
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<FooterProps, FooterState> {
+ public render() {
+ return (
+ <div className="relative pb4 pt2" style={{backgroundColor: CUSTOM_DARK_GRAY}}>
+ <div className="mx-auto max-width-4 md-px2 lg-px0 py4 clearfix" style={{color: 'white'}}>
+ <div className="col lg-col-4 md-col-4 col-12 left">
+ <div className="sm-mx-auto" style={{width: 148}}>
+ <div>
+ <img src="/images/protocol_logo_white.png" height="30" />
+ </div>
+ <div style={{fontSize: 11, color: CUSTOM_LIGHTEST_GRAY, paddingLeft: 37, paddingTop: 2}}>
+ © ZeroEx, Intl.
+ </div>
+ </div>
+ </div>
+ <div className="col lg-col-8 md-col-8 col-12 lg-pl4 md-pl4">
+ <div className="col lg-col-4 md-col-4 col-12">
+ <div className="lg-right md-right sm-center">
+ {this.renderHeader(Sections.Documentation)}
+ {_.map(menuItemsBySection[Sections.Documentation], this.renderMenuItem.bind(this))}
+ </div>
+ </div>
+ <div className="col lg-col-4 md-col-4 col-12 lg-pr2 md-pr2">
+ <div className="lg-right md-right sm-center">
+ {this.renderHeader(Sections.Community)}
+ {_.map(menuItemsBySection[Sections.Community], this.renderMenuItem.bind(this))}
+ </div>
+ </div>
+ <div className="col lg-col-4 md-col-4 col-12">
+ <div className="lg-right md-right sm-center">
+ {this.renderHeader(Sections.Organization)}
+ {_.map(menuItemsBySection[Sections.Organization], this.renderMenuItem.bind(this))}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+ private renderIcon(fileName: string) {
+ return (
+ <div style={{height: ICON_DIMENSION, width: ICON_DIMENSION}}>
+ <img src={`/images/social/${fileName}`} style={{width: ICON_DIMENSION}} />
+ </div>
+ );
+ }
+ private renderMenuItem(item: FooterMenuItem) {
+ const iconIfExists = titleToIcon[item.title];
+ return (
+ <div
+ key={item.title}
+ className="sm-center"
+ style={{fontSize: 13, paddingTop: 25}}
+ >
+ {item.isExternal ?
+ <a
+ className="text-decoration-none"
+ style={linkStyle}
+ target="_blank"
+ href={item.path}
+ >
+ {!_.isUndefined(iconIfExists) ?
+ <div className="sm-mx-auto" style={{width: 65}}>
+ <div className="flex">
+ <div className="pr1">
+ {this.renderIcon(iconIfExists)}
+ </div>
+ <div>{item.title}</div>
+ </div>
+ </div> :
+ item.title
+ }
+ </a> :
+ <Link
+ to={item.path}
+ style={linkStyle}
+ className="text-decoration-none"
+ >
+ <div>
+ {!_.isUndefined(iconIfExists) &&
+ <div className="pr1">
+ {this.renderIcon(iconIfExists)}
+ </div>
+ }
+ {item.title}
+ </div>
+ </Link>
+ }
+ </div>
+ );
+ }
+ private renderHeader(title: string) {
+ const headerStyle = {
+ textTransform: 'uppercase',
+ color: CUSTOM_LIGHT_GRAY,
+ letterSpacing: 2,
+ fontFamily: 'Roboto Mono',
+ fontSize: 13,
+ };
+ return (
+ <div
+ className="lg-pb2 md-pb2 sm-pt4"
+ style={headerStyle}
+ >
+ {title}
+ </div>
+ );
+ }
+ private renderHomepageLink(title: string) {
+ const hash = title.toLowerCase();
+ if (this.props.location.pathname === WebsitePaths.Home) {
+ return (
+ <ScrollLink
+ style={linkStyle}
+ to={hash}
+ smooth={true}
+ offset={0}
+ duration={constants.HOME_SCROLL_DURATION_MS}
+ containerId="home"
+ >
+ {title}
+ </ScrollLink>
+ );
+ } else {
+ return (
+ <HashLink
+ to={`/#${hash}`}
+ className="text-decoration-none"
+ style={linkStyle}
+ >
+ {title}
+ </HashLink>
+ );
+ }
+ }
+}
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<AssetPickerProps, AssetPickerState> {
+ public static defaultProps: Partial<AssetPickerProps> = {
+ 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: [
+ <FlatButton
+ key="noTracking"
+ label="No"
+ onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, false)}
+ />,
+ <FlatButton
+ key="yesTrack"
+ label="Yes"
+ onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, true)}
+ />,
+ ],
+ },
+ };
+ }
+ public render() {
+ const dialogConfigs: DialogConfigs = this.dialogConfigsByAssetView[this.state.assetView];
+ return (
+ <Dialog
+ title={dialogConfigs.title}
+ titleStyle={{fontWeight: 100}}
+ modal={dialogConfigs.isModal}
+ open={this.props.isOpen}
+ actions={dialogConfigs.actions}
+ onRequestClose={this.onCloseDialog.bind(this)}
+ >
+ {this.state.assetView === AssetViews.ASSET_PICKER &&
+ this.renderAssetPicker()
+ }
+ {this.state.assetView === AssetViews.NEW_TOKEN_FORM &&
+ <NewTokenForm
+ blockchain={this.props.blockchain}
+ onNewTokenSubmitted={this.onNewTokenSubmitted.bind(this)}
+ tokenByAddress={this.props.tokenByAddress}
+ />
+ }
+ {this.state.assetView === AssetViews.CONFIRM_TRACK_TOKEN &&
+ this.renderConfirmTrackToken()
+ }
+ </Dialog>
+ );
+ }
+ private renderConfirmTrackToken() {
+ const token = this.props.tokenByAddress[this.state.chosenTrackTokenAddress];
+ return (
+ <TrackTokenConfirmation
+ tokens={[token]}
+ tokenByAddress={this.props.tokenByAddress}
+ networkId={this.props.networkId}
+ isAddingTokenToTracked={this.state.isAddingTokenToTracked}
+ />
+ );
+ }
+ private renderAssetPicker() {
+ return (
+ <div
+ className="clearfix flex flex-wrap"
+ style={{overflowY: 'auto', maxWidth: 720, maxHeight: 356, marginBottom: 10}}
+ >
+ {this.renderGridTiles()}
+ </div>
+ );
+ }
+ 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 (
+ <div
+ key={address}
+ style={{width: TILE_DIMENSION, height: TILE_DIMENSION, ...tileStyles}}
+ className="p2 mx-auto"
+ onClick={this.onChooseToken.bind(this, address)}
+ onMouseEnter={this.onToggleHover.bind(this, address, true)}
+ onMouseLeave={this.onToggleHover.bind(this, address, false)}
+ >
+ <div className="p1 center">
+ <TokenIcon token={token} diameter={TOKEN_ICON_DIMENSION} />
+ </div>
+ <div className="center">{token.name}</div>
+ </div>
+ );
+ });
+ const otherTokenKey = 'otherToken';
+ isHovered = this.state.hoveredAddress === otherTokenKey;
+ tileStyles = {
+ cursor: 'pointer',
+ opacity: isHovered ? 0.6 : 1,
+ };
+ if (this.props.tokenVisibility !== TokenVisibility.TRACKED) {
+ gridTiles.push((
+ <div
+ key={otherTokenKey}
+ style={{width: TILE_DIMENSION, height: TILE_DIMENSION, ...tileStyles}}
+ className="p2 mx-auto"
+ onClick={this.onCustomAssetChosen.bind(this)}
+ onMouseEnter={this.onToggleHover.bind(this, otherTokenKey, true)}
+ onMouseLeave={this.onToggleHover.bind(this, otherTokenKey, false)}
+ >
+ <div className="p1 center">
+ <i
+ style={{fontSize: 105, paddingLeft: 1, paddingRight: 1}}
+ className="zmdi zmdi-plus-circle"
+ />
+ </div>
+ <div className="center">Other ERC20 Token</div>
+ </div>
+ ));
+ }
+ 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<GenerateOrderFormProps, any> {
+ 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<br> \
+ allowed to fill this order. If no taker is<br> \
+ specified, anyone is able to fill it.';
+ const exchangeContractIfExists = this.props.blockchain.getExchangeContractAddressIfExists();
+ return (
+ <div className="clearfix mb2 lg-px4 md-px4 sm-px2">
+ <h3>Generate an order</h3>
+ <Divider />
+ <div className="mx-auto" style={{maxWidth: 580}}>
+ <div className="pt3">
+ <div className="mx-auto clearfix">
+ <div className="lg-col md-col lg-col-5 md-col-5 sm-col sm-col-5 sm-pb2">
+ <TokenInput
+ userAddress={this.props.userAddress}
+ blockchain={this.props.blockchain}
+ blockchainErr={this.props.blockchainErr}
+ dispatcher={this.props.dispatcher}
+ label="Selling"
+ side={Side.deposit}
+ networkId={this.props.networkId}
+ assetToken={this.props.sideToAssetToken[Side.deposit]}
+ updateChosenAssetToken={dispatcher.updateChosenAssetToken.bind(dispatcher)}
+ tokenByAddress={this.props.tokenByAddress}
+ />
+ <TokenAmountInput
+ label="Sell amount"
+ token={depositToken}
+ tokenState={depositTokenState}
+ amount={this.props.sideToAssetToken[Side.deposit].amount}
+ onChange={this.onTokenAmountChange.bind(this, depositToken, Side.deposit)}
+ shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs}
+ shouldCheckBalance={true}
+ shouldCheckAllowance={true}
+ />
+ </div>
+ <div className="lg-col md-col lg-col-2 md-col-2 sm-col sm-col-2 xs-hide">
+ <div className="p1">
+ <SwapIcon
+ swapTokensFn={dispatcher.swapAssetTokenSymbols.bind(dispatcher)}
+ />
+ </div>
+ </div>
+ <div className="lg-col md-col lg-col-5 md-col-5 sm-col sm-col-5 sm-pb2">
+ <TokenInput
+ userAddress={this.props.userAddress}
+ blockchain={this.props.blockchain}
+ blockchainErr={this.props.blockchainErr}
+ dispatcher={this.props.dispatcher}
+ label="Buying"
+ side={Side.receive}
+ networkId={this.props.networkId}
+ assetToken={this.props.sideToAssetToken[Side.receive]}
+ updateChosenAssetToken={dispatcher.updateChosenAssetToken.bind(dispatcher)}
+ tokenByAddress={this.props.tokenByAddress}
+ />
+ <TokenAmountInput
+ label="Receive amount"
+ token={receiveToken}
+ tokenState={receiveTokenState}
+ amount={this.props.sideToAssetToken[Side.receive].amount}
+ onChange={this.onTokenAmountChange.bind(this, receiveToken, Side.receive)}
+ shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs}
+ shouldCheckBalance={false}
+ shouldCheckAllowance={false}
+ />
+ </div>
+ </div>
+ </div>
+ <div className="pt1 sm-pb2 lg-px4 md-px4">
+ <div className="lg-px3 md-px3">
+ <div style={{fontSize: 12, color: colors.grey500}}>Expiration</div>
+ <ExpirationInput
+ orderExpiryTimestamp={this.props.orderExpiryTimestamp}
+ updateOrderExpiry={dispatcher.updateOrderExpiry.bind(dispatcher)}
+ />
+ </div>
+ </div>
+ <div className="pt1 flex mx-auto">
+ <IdenticonAddressInput
+ label="Taker"
+ initialAddress={this.props.orderTakerAddress}
+ updateOrderAddress={this.updateOrderAddress.bind(this)}
+ />
+ <div className="pt3">
+ <div className="pl1">
+ <HelpTooltip
+ explanation={takerExplanation}
+ />
+ </div>
+ </div>
+ </div>
+ <div>
+ <HashInput
+ blockchain={this.props.blockchain}
+ blockchainIsLoaded={this.props.blockchainIsLoaded}
+ hashData={this.props.hashData}
+ label="Order Hash"
+ />
+ </div>
+ <div className="pt2">
+ <div className="center">
+ <LifeCycleRaisedButton
+ labelReady="Sign hash"
+ labelLoading="Signing..."
+ labelComplete="Hash signed!"
+ onClickAsyncFn={this.onSignClickedAsync.bind(this)}
+ />
+ </div>
+ {this.state.globalErrMsg !== '' &&
+ <Alert type={AlertTypes.ERROR} message={this.state.globalErrMsg} />
+ }
+ </div>
+ </div>
+ <Dialog
+ title="Order JSON"
+ titleStyle={{fontWeight: 100}}
+ modal={false}
+ open={this.state.signingState === SigningState.SIGNED}
+ onRequestClose={this.onCloseOrderJSONDialog.bind(this)}
+ >
+ <OrderJSON
+ exchangeContractIfExists={exchangeContractIfExists}
+ orderExpiryTimestamp={this.props.orderExpiryTimestamp}
+ orderSignatureData={this.props.orderSignatureData}
+ orderTakerAddress={this.props.orderTakerAddress}
+ orderMakerAddress={this.props.userAddress}
+ orderSalt={this.props.orderSalt}
+ orderMakerFee={this.props.hashData.makerFee}
+ orderTakerFee={this.props.hashData.takerFee}
+ orderFeeRecipient={this.props.hashData.feeRecipientAddress}
+ networkId={this.props.networkId}
+ sideToAssetToken={this.props.sideToAssetToken}
+ tokenByAddress={this.props.tokenByAddress}
+ />
+ </Dialog>
+ </div>
+ );
+ }
+ 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<boolean> {
+ 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<boolean> {
+ 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<NewTokenFormProps, NewTokenFormState> {
+ constructor(props: NewTokenFormProps) {
+ super(props);
+ this.state = {
+ address: '',
+ globalErrMsg: '',
+ name: '',
+ nameErrText: '',
+ shouldShowAddressIncompleteErr: false,
+ symbol: '',
+ symbolErrText: '',
+ decimals: '18',
+ decimalsErrText: '',
+ };
+ }
+ public render() {
+ return (
+ <div className="mx-auto pb2" style={{width: 256}}>
+ <div>
+ <TextField
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500}}
+ floatingLabelText={<RequiredLabel label="Name" />}
+ value={this.state.name}
+ errorText={this.state.nameErrText}
+ onChange={this.onTokenNameChanged.bind(this)}
+ />
+ </div>
+ <div>
+ <TextField
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500}}
+ floatingLabelText={<RequiredLabel label="Symbol" />}
+ value={this.state.symbol}
+ errorText={this.state.symbolErrText}
+ onChange={this.onTokenSymbolChanged.bind(this)}
+ />
+ </div>
+ <div>
+ <AddressInput
+ isRequired={true}
+ label="Contract address"
+ initialAddress=""
+ shouldShowIncompleteErrs={this.state.shouldShowAddressIncompleteErr}
+ updateAddress={this.onTokenAddressChanged.bind(this)}
+ />
+ </div>
+ <div>
+ <TextField
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500}}
+ floatingLabelText={<RequiredLabel label="Decimals" />}
+ value={this.state.decimals}
+ errorText={this.state.decimalsErrText}
+ onChange={this.onTokenDecimalsChanged.bind(this)}
+ />
+ </div>
+ <div className="pt2 mx-auto" style={{width: 120}}>
+ <LifeCycleRaisedButton
+ labelReady="Add"
+ labelLoading="Adding..."
+ labelComplete="Added!"
+ onClickAsyncFn={this.onAddNewTokenClickAsync.bind(this)}
+ />
+ </div>
+ {this.state.globalErrMsg !== '' &&
+ <Alert type={AlertTypes.ERROR} message={this.state.globalErrMsg} />
+ }
+ </div>
+ );
+ }
+ 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<AddressInputProps, AddressInputState> {
+ 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 ? <RequiredLabel label={this.props.label} /> :
+ this.props.label;
+ const labelDisplay = this.props.shouldHideLabel ? 'hidden' : 'block';
+ const hintText = this.props.hintText ? this.props.hintText : '';
+ return (
+ <div className="overflow-hidden">
+ <TextField
+ id={`address-field-${this.props.label}`}
+ disabled={_.isUndefined(this.props.disabled) ? false : this.props.disabled}
+ fullWidth={true}
+ hintText={hintText}
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500, display: labelDisplay}}
+ floatingLabelText={label}
+ errorText={this.state.errMsg}
+ value={this.state.address}
+ onChange={this.onOrderTakerAddressUpdated.bind(this)}
+ />
+ </div>
+ );
+ }
+ 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<AllowanceToggleProps, AllowanceToggleState> {
+ 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 (
+ <div className="flex">
+ <div>
+ <Toggle
+ disabled={this.state.isSpinnerVisible}
+ toggled={this.isAllowanceSet()}
+ onToggle={this.onToggleAllowanceAsync.bind(this, this.props.token)}
+ />
+ </div>
+ {this.state.isSpinnerVisible &&
+ <div className="pl1" style={{paddingTop: 3}}>
+ <i className="zmdi zmdi-spinner zmdi-hc-spin" />
+ </div>
+ }
+ </div>
+ );
+ }
+ 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<BalanceBoundedInputProps, BalanceBoundedInputState> {
+ public static defaultProps: Partial<BalanceBoundedInputProps> = {
+ 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 = <RequiredLabel label={this.props.label}/>;
+ }
+ return (
+ <TextField
+ fullWidth={true}
+ floatingLabelText={label}
+ floatingLabelFixed={true}
+ floatingLabelStyle={{color: colors.grey500, width: 206}}
+ errorText={errorText}
+ value={this.state.amountString}
+ hintText={<span style={{textTransform: 'capitalize'}}>amount</span>}
+ 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 (
+ <span>
+ Insufficient balance.{' '}
+ {this.renderIncreaseBalanceLink()}
+ </span>
+ );
+ }
+ 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 (
+ <Link
+ to={`${WebsitePaths.Portal}/balances`}
+ style={linkStyle}
+ >
+ {increaseBalanceText}
+ </Link>
+ );
+ } else {
+ return (
+ <div
+ onClick={this.props.onVisitBalancesPageClick}
+ style={linkStyle}
+ >
+ {increaseBalanceText}
+ </div>
+ );
+ }
+ }
+}
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<EthAmountInputProps, EthAmountInputState> {
+ public render() {
+ const amount = this.props.amount ?
+ ZeroEx.toUnitAmount(this.props.amount, constants.ETH_DECIMAL_PLACES) :
+ undefined;
+ return (
+ <div className="flex overflow-hidden" style={{height: 63}}>
+ <BalanceBoundedInput
+ label={this.props.label}
+ balance={this.props.balance}
+ amount={amount}
+ onChange={this.onChange.bind(this)}
+ shouldCheckBalance={this.props.shouldCheckBalance}
+ shouldShowIncompleteErrs={this.props.shouldShowIncompleteErrs}
+ onVisitBalancesPageClick={this.props.onVisitBalancesPageClick}
+ shouldHideVisitBalancesLink={this.props.shouldHideVisitBalancesLink}
+ />
+ <div style={{paddingTop: _.isUndefined(this.props.label) ? 15 : 40}}>
+ ETH
+ </div>
+ </div>
+ );
+ }
+ 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<ExpirationInputProps, ExpirationInputState> {
+ 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 (
+ <div className="clearfix">
+ <div className="col col-6 overflow-hidden pr3 flex relative">
+ <DatePicker
+ className="overflow-hidden"
+ hintText="Date"
+ mode="landscape"
+ autoOk={true}
+ value={date}
+ onChange={this.onDateChanged.bind(this)}
+ shouldDisableDate={this.shouldDisableDate.bind(this)}
+ />
+ <div
+ className="absolute"
+ style={{fontSize: 20, right: 40, top: 13, pointerEvents: 'none'}}
+ >
+ <i className="zmdi zmdi-calendar" />
+ </div>
+ </div>
+ <div className="col col-5 overflow-hidden flex relative">
+ <TimePicker
+ className="overflow-hidden"
+ hintText="Time"
+ autoOk={true}
+ value={time}
+ onChange={this.onTimeChanged.bind(this)}
+ />
+ <div
+ className="absolute"
+ style={{fontSize: 20, right: 9, top: 13, pointerEvents: 'none'}}
+ >
+ <i className="zmdi zmdi-time" />
+ </div>
+ </div>
+ <div
+ onClick={this.clearDates.bind(this)}
+ className="col col-1 pt2"
+ style={{textAlign: 'right'}}
+ >
+ <i style={{fontSize: 16, cursor: 'pointer'}} className="zmdi zmdi-close" />
+ </div>
+ </div>
+ );
+ }
+ 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<HashInputProps, HashInputState> {
+ public render() {
+ const msgHashHex = this.props.blockchainIsLoaded ? this.generateMessageHashHex() : '';
+ return (
+ <div>
+ <FakeTextField label={this.props.label}>
+ <div
+ style={styles.textField}
+ data-tip={true}
+ data-for="hashTooltip"
+ >
+ {msgHashHex}
+ </div>
+ </FakeTextField>
+ <ReactTooltip id="hashTooltip">{msgHashHex}</ReactTooltip>
+ </div>
+ );
+ }
+ 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<IdenticonAddressInputProps, IdenticonAddressInputState> {
+ constructor(props: IdenticonAddressInputProps) {
+ super(props);
+ this.state = {
+ address: props.initialAddress,
+ };
+ }
+ public render() {
+ const label = this.props.isRequired ? <RequiredLabel label={this.props.label} /> :
+ this.props.label;
+ return (
+ <div className="relative" style={{width: '100%'}}>
+ <InputLabel text={label} />
+ <div className="flex">
+ <div className="col col-1 pb1 pr1" style={{paddingTop: 13}}>
+ <Identicon address={this.state.address} diameter={26} />
+ </div>
+ <div className="col col-11 pb1 pl1" style={{height: 65}}>
+ <AddressInput
+ hintText="e.g 0x75bE4F78AA3699B3A348c84bDB2a96c3Db..."
+ shouldHideLabel={true}
+ initialAddress={this.props.initialAddress}
+ updateAddress={this.updateAddress.bind(this)}
+ />
+ </div>
+ </div>
+ </div>
+ );
+ }
+ 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<TokenAmountInputProps, TokenAmountInputState> {
+ public render() {
+ const amount = this.props.amount ?
+ ZeroEx.toUnitAmount(this.props.amount, this.props.token.decimals) :
+ undefined;
+ return (
+ <div className="flex overflow-hidden" style={{height: 84}}>
+ <BalanceBoundedInput
+ label={this.props.label}
+ amount={amount}
+ balance={ZeroEx.toUnitAmount(this.props.tokenState.balance, this.props.token.decimals)}
+ onChange={this.onChange.bind(this)}
+ validate={this.validate.bind(this)}
+ shouldCheckBalance={this.props.shouldCheckBalance}
+ shouldShowIncompleteErrs={this.props.shouldShowIncompleteErrs}
+ onVisitBalancesPageClick={this.props.onVisitBalancesPageClick}
+ />
+ <div style={{paddingTop: 39}}>
+ {this.props.token.symbol}
+ </div>
+ </div>
+ );
+ }
+ 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 (
+ <span>
+ Insufficient allowance.{' '}
+ <Link
+ to={`${WebsitePaths.Portal}/balances`}
+ style={{cursor: 'pointer', color: colors.grey900}}
+ >
+ Set allowance
+ </Link>
+ </span>
+ );
+ }
+ }
+}
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<TokenInputProps, TokenInputState> {
+ 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 (
+ <div className="relative">
+ <div className="pb1">
+ <InputLabel text={this.props.label} />
+ </div>
+ <Paper
+ zDepth={1}
+ style={{cursor: 'pointer'}}
+ onMouseEnter={this.onToggleHover.bind(this, true)}
+ onMouseLeave={this.onToggleHover.bind(this, false)}
+ onClick={this.onAssetClicked.bind(this)}
+ >
+ <div
+ className="mx-auto pt2"
+ style={{width: TOKEN_ICON_DIMENSION, ...iconStyles}}
+ >
+ <TokenIcon token={token} diameter={TOKEN_ICON_DIMENSION} />
+ </div>
+ <div className="py1 center" style={{color: colors.grey500}}>
+ {token.name}
+ </div>
+ </Paper>
+ <AssetPicker
+ userAddress={this.props.userAddress}
+ networkId={this.props.networkId}
+ blockchain={this.props.blockchain}
+ dispatcher={this.props.dispatcher}
+ isOpen={this.state.isPickerOpen}
+ currentTokenAddress={this.props.assetToken.address}
+ onTokenChosen={this.onTokenChosen.bind(this)}
+ tokenByAddress={this.props.tokenByAddress}
+ />
+ </div>
+ );
+ }
+ 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<OrderJSONProps, OrderJSONState> {
+ 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 (
+ <div>
+ <div className="pb2">
+ 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.
+ </div>
+ <div className="pb2 flex">
+ <div
+ className="inline-block pl1"
+ style={{top: 1}}
+ >
+ <CopyIcon data={orderJSON} callToAction="Copy" />
+ </div>
+ </div>
+ <Paper className="center overflow-hidden">
+ <TextField
+ id="orderJSON"
+ style={{width: 710}}
+ value={JSON.stringify(order, null, '\t')}
+ multiLine={true}
+ rows={2}
+ rowsMax={8}
+ underlineStyle={{display: 'none'}}
+ />
+ </Paper>
+ <div className="pt3 pb2 center">
+ <div>
+ Share your signed order!
+ </div>
+ <div>
+ <div className="mx-auto overflow-hidden" style={{width: 152}}>
+ <TextField
+ id={`${this.state.shareLink}-bitly`}
+ value={this.state.shareLink}
+ />
+ </div>
+ </div>
+ <div className="mx-auto pt1 flex" style={{width: 91}}>
+ <div>
+ <i
+ style={{cursor: 'pointer', fontSize: 29}}
+ onClick={this.shareViaFacebook.bind(this)}
+ className="zmdi zmdi-facebook-box"
+ />
+ </div>
+ <div className="pl1" style={{position: 'relative', width: 28}}>
+ <i
+ style={{cursor: 'pointer', fontSize: 32, position: 'absolute', top: -2, left: 8}}
+ onClick={this.shareViaEmailAsync.bind(this)}
+ className="zmdi zmdi-email"
+ />
+ </div>
+ <div className="pl1">
+ <i
+ style={{cursor: 'pointer', fontSize: 29}}
+ onClick={this.shareViaTwitterAsync.bind(this)}
+ className="zmdi zmdi-twitter-box"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+ 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<string> {
+ 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<PortalAllProps, PortalAllState> {
+ 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 (
+ <div style={portalStyle}>
+ <DocumentTitle title="0x Portal DApp"/>
+ <TopBar
+ userAddress={this.props.userAddress}
+ blockchainIsLoaded={this.props.blockchainIsLoaded}
+ location={this.props.location}
+ />
+ <div id="portal" className="mx-auto max-width-4 pt4" style={{width: '100%'}}>
+ <Paper className="mb3 mt2">
+ {!configs.isMainnetEnabled && this.props.networkId === constants.MAINNET_NETWORK_ID ?
+ <div className="p3 center">
+ <div className="h2 py2">Mainnet unavailable</div>
+ <div className="mx-auto pb2 pt2">
+ <img
+ src="/images/zrx_token.png"
+ style={{width: 150}}
+ />
+ </div>
+ <div>
+ 0x portal is currently unavailable on the Ethereum mainnet.
+ <div>
+ To try it out, switch to the Kovan test network
+ (networkId: 42).
+ </div>
+ <div className="py2">
+ Check back soon!
+ </div>
+ </div>
+ </div> :
+ <div className="mx-auto flex">
+ <div
+ className="col col-2 pr2 pt1 sm-hide xs-hide"
+ style={{overflow: 'hidden', backgroundColor: 'rgb(39, 39, 39)', color: 'white'}}
+ >
+ <PortalMenu menuItemStyle={{color: 'white'}} />
+ </div>
+ <div className="col col-12 lg-col-10 md-col-10 sm-col sm-col-12">
+ <div className="py2" style={{backgroundColor: colors.grey50}}>
+ {this.props.blockchainIsLoaded ?
+ <Switch>
+ <Route
+ path={`${WebsitePaths.Portal}/fill`}
+ render={this.renderFillOrder.bind(this)}
+ />
+ <Route
+ path={`${WebsitePaths.Portal}/balances`}
+ render={this.renderTokenBalances.bind(this)}
+ />
+ <Route
+ path={`${WebsitePaths.Portal}/trades`}
+ component={this.renderTradeHistory.bind(this)}
+ />
+ <Route
+ path={`${WebsitePaths.Home}`}
+ render={this.renderGenerateOrderForm.bind(this)}
+ />
+ </Switch> :
+ <Loading />
+ }
+ </div>
+ </div>
+ </div>
+ }
+ </Paper>
+ <BlockchainErrDialog
+ blockchain={this.blockchain}
+ blockchainErr={this.props.blockchainErr}
+ isOpen={this.props.shouldBlockchainErrDialogBeOpen}
+ userAddress={this.props.userAddress}
+ toggleDialogFn={updateShouldBlockchainErrDialogBeOpen}
+ networkId={this.props.networkId}
+ />
+ <PortalDisclaimerDialog
+ isOpen={!this.state.hasAcceptedDisclaimer}
+ onToggleDialog={this.onPortalDisclaimerAccepted.bind(this)}
+ />
+ <FlashMessage
+ dispatcher={this.props.dispatcher}
+ flashMessage={this.props.flashMessage}
+ />
+ </div>
+ <Footer location={this.props.location} />
+ </div>
+ );
+ }
+ private renderTradeHistory() {
+ return (
+ <TradeHistory
+ tokenByAddress={this.props.tokenByAddress}
+ userAddress={this.props.userAddress}
+ networkId={this.props.networkId}
+ />
+ );
+ }
+ private renderTokenBalances() {
+ return (
+ <TokenBalances
+ blockchain={this.blockchain}
+ blockchainErr={this.props.blockchainErr}
+ blockchainIsLoaded={this.props.blockchainIsLoaded}
+ dispatcher={this.props.dispatcher}
+ screenWidth={this.props.screenWidth}
+ tokenByAddress={this.props.tokenByAddress}
+ tokenStateByAddress={this.props.tokenStateByAddress}
+ userAddress={this.props.userAddress}
+ userEtherBalance={this.props.userEtherBalance}
+ networkId={this.props.networkId}
+ />
+ );
+ }
+ private renderFillOrder(match: any, location: Location, history: History) {
+ const initialFillOrder = !_.isUndefined(this.props.userSuppliedOrderCache) ?
+ this.props.userSuppliedOrderCache :
+ this.sharedOrderIfExists;
+ return (
+ <FillOrder
+ blockchain={this.blockchain}
+ blockchainErr={this.props.blockchainErr}
+ initialOrder={initialFillOrder}
+ isOrderInUrl={!_.isUndefined(this.sharedOrderIfExists)}
+ orderFillAmount={this.props.orderFillAmount}
+ networkId={this.props.networkId}
+ userAddress={this.props.userAddress}
+ tokenByAddress={this.props.tokenByAddress}
+ tokenStateByAddress={this.props.tokenStateByAddress}
+ dispatcher={this.props.dispatcher}
+ />
+ );
+ }
+ private renderGenerateOrderForm(match: any, location: Location, history: History) {
+ return (
+ <GenerateOrderForm
+ blockchain={this.blockchain}
+ hashData={this.props.hashData}
+ dispatcher={this.props.dispatcher}
+ />
+ );
+ }
+ 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<PortalMenuProps, PortalMenuState> {
+ public static defaultProps: Partial<PortalMenuProps> = {
+ onClick: _.noop,
+ };
+ public render() {
+ return (
+ <div>
+ <MenuItem
+ style={this.props.menuItemStyle}
+ className="py2"
+ to={`${WebsitePaths.Portal}`}
+ onClick={this.props.onClick.bind(this)}
+ >
+ {this.renderMenuItemWithIcon('Generate order', 'zmdi-arrow-right-top')}
+ </MenuItem>
+ <MenuItem
+ style={this.props.menuItemStyle}
+ className="py2"
+ to={`${WebsitePaths.Portal}/fill`}
+ onClick={this.props.onClick.bind(this)}
+ >
+ {this.renderMenuItemWithIcon('Fill order', 'zmdi-arrow-left-bottom')}
+ </MenuItem>
+ <MenuItem
+ style={this.props.menuItemStyle}
+ className="py2"
+ to={`${WebsitePaths.Portal}/balances`}
+ onClick={this.props.onClick.bind(this)}
+ >
+ {this.renderMenuItemWithIcon('Balances', 'zmdi-balance-wallet')}
+ </MenuItem>
+ <MenuItem
+ style={this.props.menuItemStyle}
+ className="py2"
+ to={`${WebsitePaths.Portal}/trades`}
+ onClick={this.props.onClick.bind(this)}
+ >
+ {this.renderMenuItemWithIcon('Trade history', 'zmdi-format-list-bulleted')}
+ </MenuItem>
+ </div>
+ );
+ }
+ private renderMenuItemWithIcon(title: string, iconName: string) {
+ return (
+ <div className="flex" style={{fontWeight: 100}}>
+ <div className="pr1 pl2">
+ <i style={{fontSize: 20}} className={`zmdi ${iconName}`} />
+ </div>
+ <div className="pl1">
+ {title}
+ </div>
+ </div>
+ );
+ }
+}
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<SendButtonProps, SendButtonState> {
+ public constructor(props: SendButtonProps) {
+ super(props);
+ this.state = {
+ isSendDialogVisible: false,
+ isSending: false,
+ };
+ }
+ public render() {
+ const labelStyle = this.state.isSending ? {fontSize: 10} : {};
+ return (
+ <div>
+ <RaisedButton
+ style={{width: '100%'}}
+ labelStyle={labelStyle}
+ disabled={this.state.isSending}
+ label={this.state.isSending ? 'Sending...' : 'Send'}
+ onClick={this.toggleSendDialog.bind(this)}
+ />
+ <SendDialog
+ isOpen={this.state.isSendDialogVisible}
+ onComplete={this.onSendAmountSelectedAsync.bind(this)}
+ onCancelled={this.toggleSendDialog.bind(this)}
+ token={this.props.token}
+ tokenState={this.props.tokenState}
+ />
+ </div>
+ );
+ }
+ 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<TokenBalancesProps, TokenBalancesState> {
+ 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 = [
+ <FlatButton
+ label="Ok"
+ primary={true}
+ onTouchTap={this.onErrorDialogToggle.bind(this, false)}
+ />,
+ ];
+ const dharmaDialogActions = [
+ <FlatButton
+ label="Close"
+ primary={true}
+ onTouchTap={this.onDharmaDialogToggle.bind(this, false)}
+ />,
+ ];
+ 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,<br> \
+ you can request a loan from the Dharma Loan<br> \
+ network. Your loan should be funded in 5<br> \
+ minutes or less.';
+ const allowanceExplanation = '0x smart contracts require access to your<br> \
+ token balances in order to execute trades.<br> \
+ Toggling permissions sets an allowance for the<br> \
+ smart contract so you can start trading that token.';
+ return (
+ <div className="lg-px4 md-px4 sm-px1 pb2">
+ <h3>{isTestNetwork ? 'Test ether' : 'Ether'}</h3>
+ <Divider />
+ <div className="pt2 pb2">
+ {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.'
+ }
+ </div>
+ <Table
+ selectable={false}
+ style={styles.bgColor}
+ >
+ <TableHeader displaySelectAll={false} adjustForCheckbox={false}>
+ <TableRow>
+ <TableHeaderColumn>Currency</TableHeaderColumn>
+ <TableHeaderColumn>Balance</TableHeaderColumn>
+ <TableRowColumn
+ className="sm-hide xs-hide"
+ style={stubColumnStyle}
+ />
+ {
+ isTestNetwork &&
+ <TableHeaderColumn
+ style={{paddingLeft: 3}}
+ >
+ {isSmallScreen ? 'Faucet' : 'Request from faucet'}
+ </TableHeaderColumn>
+ }
+ {
+ isTestNetwork &&
+ <TableHeaderColumn
+ style={dharmaButtonColumnStyle}
+ >
+ {isSmallScreen ? 'Loan' : 'Request Dharma loan'}
+ <HelpTooltip
+ style={{paddingLeft: 4}}
+ explanation={dharmaLoanExplanation}
+ />
+ </TableHeaderColumn>
+ }
+ </TableRow>
+ </TableHeader>
+ <TableBody displayRowCheckbox={false}>
+ <TableRow key="ETH">
+ <TableRowColumn className="py1">
+ <img
+ style={{width: ICON_DIMENSION, height: ICON_DIMENSION}}
+ src={ETHER_ICON_PATH}
+ />
+ </TableRowColumn>
+ <TableRowColumn>
+ {this.props.userEtherBalance.toFixed(PRECISION)} ETH
+ {this.state.isBalanceSpinnerVisible &&
+ <span className="pl1">
+ <i className="zmdi zmdi-spinner zmdi-hc-spin" />
+ </span>
+ }
+ </TableRowColumn>
+ <TableRowColumn
+ className="sm-hide xs-hide"
+ style={stubColumnStyle}
+ />
+ {
+ isTestNetwork &&
+ <TableRowColumn style={{paddingLeft: 3}}>
+ <LifeCycleRaisedButton
+ labelReady="Request"
+ labelLoading="Sending..."
+ labelComplete="Sent!"
+ onClickAsyncFn={this.faucetRequestAsync.bind(this, true)}
+ />
+ </TableRowColumn>
+ }
+ {
+ isTestNetwork &&
+ <TableRowColumn style={dharmaButtonColumnStyle}>
+ <RaisedButton
+ label="Request"
+ style={{width: '100%'}}
+ onTouchTap={this.onDharmaDialogToggle.bind(this)}
+ />
+ </TableRowColumn>
+ }
+ </TableRow>
+ </TableBody>
+ </Table>
+ <div className="clearfix" style={{paddingBottom: 1}}>
+ <div className="col col-10">
+ <h3 className="pt2">
+ {isTestNetwork ? 'Test tokens' : 'Tokens'}
+ </h3>
+ </div>
+ <div className="col col-1 pt3 align-right">
+ <FloatingActionButton
+ mini={true}
+ zDepth={0}
+ onClick={this.onAddTokenClicked.bind(this)}
+ >
+ <ContentAdd />
+ </FloatingActionButton>
+ </div>
+ <div className="col col-1 pt3 align-right">
+ <FloatingActionButton
+ mini={true}
+ zDepth={0}
+ onClick={this.onRemoveTokenClicked.bind(this)}
+ >
+ <ContentRemove />
+ </FloatingActionButton>
+ </div>
+ </div>
+ <Divider />
+ <div className="pt2 pb2">
+ {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.'
+ }
+ </div>
+ <Table
+ selectable={false}
+ bodyStyle={{height: tokenTableHeight}}
+ style={styles.bgColor}
+ >
+ <TableHeader displaySelectAll={false} adjustForCheckbox={false}>
+ <TableRow>
+ <TableHeaderColumn
+ colSpan={tokenColSpan}
+ >
+ Token
+ </TableHeaderColumn>
+ <TableHeaderColumn style={{paddingLeft: 3}}>Balance</TableHeaderColumn>
+ <TableHeaderColumn>
+ <div className="inline-block">{!isSmallScreen && 'Trade '}Permissions</div>
+ <HelpTooltip
+ style={{paddingLeft: 4}}
+ explanation={allowanceExplanation}
+ />
+ </TableHeaderColumn>
+ <TableHeaderColumn>
+ Action
+ </TableHeaderColumn>
+ {this.props.screenWidth !== ScreenWidths.SM &&
+ <TableHeaderColumn>
+ Send
+ </TableHeaderColumn>
+ }
+ </TableRow>
+ </TableHeader>
+ <TableBody displayRowCheckbox={false}>
+ {this.renderTokenTableRows()}
+ </TableBody>
+ </Table>
+ <Dialog
+ title="Oh oh"
+ titleStyle={{fontWeight: 100}}
+ actions={errorDialogActions}
+ open={!_.isUndefined(this.state.errorType)}
+ onRequestClose={this.onErrorDialogToggle.bind(this, false)}
+ >
+ {this.renderErrorDialogBody()}
+ </Dialog>
+ <Dialog
+ title="Request Dharma Loan"
+ titleStyle={{fontWeight: 100, backgroundColor: 'rgb(250, 250, 250)'}}
+ bodyStyle={{backgroundColor: 'rgb(37, 37, 37)'}}
+ actionsContainerStyle={{backgroundColor: 'rgb(250, 250, 250)'}}
+ autoScrollBodyContent={true}
+ actions={dharmaDialogActions}
+ open={this.state.isDharmaDialogVisible}
+ >
+ {this.renderDharmaLoanFrame()}
+ </Dialog>
+ <AssetPicker
+ userAddress={this.props.userAddress}
+ networkId={this.props.networkId}
+ blockchain={this.props.blockchain}
+ dispatcher={this.props.dispatcher}
+ isOpen={this.state.isTokenPickerOpen}
+ currentTokenAddress={''}
+ onTokenChosen={this.onAssetTokenPicked.bind(this)}
+ tokenByAddress={this.props.tokenByAddress}
+ tokenVisibility={this.state.isAddingToken ? TokenVisibility.UNTRACKED : TokenVisibility.TRACKED}
+ />
+ </div>
+ );
+ }
+ 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 (
+ <TableRow key={token.address} style={{height: TOKEN_TABLE_ROW_HEIGHT}}>
+ <TableRowColumn
+ colSpan={tokenColSpan}
+ >
+ {_.isUndefined(tokenLink) ?
+ this.renderTokenName(token) :
+ <a href={tokenLink} target="_blank" style={{textDecoration: 'none'}}>
+ {this.renderTokenName(token)}
+ </a>
+ }
+ </TableRowColumn>
+ <TableRowColumn style={{paddingRight: 3, paddingLeft: 3}}>
+ {this.renderAmount(tokenState.balance, token.decimals)} {token.symbol}
+ {this.state.isZRXSpinnerVisible && token.symbol === ZRX_TOKEN_SYMBOL &&
+ <span className="pl1">
+ <i className="zmdi zmdi-spinner zmdi-hc-spin" />
+ </span>
+ }
+ </TableRowColumn>
+ <TableRowColumn>
+ <AllowanceToggle
+ blockchain={this.props.blockchain}
+ dispatcher={this.props.dispatcher}
+ token={token}
+ tokenState={tokenState}
+ onErrorOccurred={this.onErrorOccurred.bind(this)}
+ userAddress={this.props.userAddress}
+ />
+ </TableRowColumn>
+ <TableRowColumn
+ style={{paddingLeft: actionPaddingX, paddingRight: actionPaddingX}}
+ >
+ {isMintable &&
+ <LifeCycleRaisedButton
+ labelReady="Mint"
+ labelLoading={<span style={{fontSize: 12}}>Minting...</span>}
+ labelComplete="Minted!"
+ onClickAsyncFn={this.onMintTestTokensAsync.bind(this, token)}
+ />
+ }
+ {token.symbol === ETHER_TOKEN_SYMBOL &&
+ <EthWethConversionButton
+ blockchain={this.props.blockchain}
+ dispatcher={this.props.dispatcher}
+ ethToken={this.getWrappedEthToken()}
+ ethTokenState={tokenState}
+ userEtherBalance={this.props.userEtherBalance}
+ onError={this.onEthWethConversionFailed.bind(this)}
+ />
+ }
+ {token.symbol === ZRX_TOKEN_SYMBOL && this.props.networkId === constants.TESTNET_NETWORK_ID &&
+ <LifeCycleRaisedButton
+ labelReady="Request"
+ labelLoading="Sending..."
+ labelComplete="Sent!"
+ onClickAsyncFn={this.faucetRequestAsync.bind(this, false)}
+ />
+ }
+ </TableRowColumn>
+ {this.props.screenWidth !== ScreenWidths.SM &&
+ <TableRowColumn
+ style={{paddingLeft: actionPaddingX, paddingRight: actionPaddingX}}
+ >
+ <SendButton
+ blockchain={this.props.blockchain}
+ dispatcher={this.props.dispatcher}
+ token={token}
+ tokenState={tokenState}
+ onError={this.onSendFailed.bind(this)}
+ />
+ </TableRowColumn>
+ }
+ </TableRow>
+ );
+ }
+ 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 (
+ <div className="flex">
+ <TokenIcon token={token} diameter={ICON_DIMENSION} />
+ <div
+ data-tip={true}
+ data-for={tooltipId}
+ className="mt2 ml2 sm-hide xs-hide"
+ >
+ {token.name}
+ </div>
+ <ReactTooltip id={tooltipId}>{token.address}</ReactTooltip>
+ </div>
+ );
+ }
+ private renderErrorDialogBody() {
+ switch (this.state.errorType) {
+ case BalanceErrs.incorrectNetworkForFaucet:
+ return (
+ <div>
+ 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.
+ </div>
+ );
+
+ case BalanceErrs.faucetRequestFailed:
+ return (
+ <div>
+ An unexpected error occurred while trying to request test Ether from our faucet.
+ {' '}Please refresh the page and try again.
+ </div>
+ );
+
+ case BalanceErrs.faucetQueueIsFull:
+ return (
+ <div>
+ Our test Ether faucet queue is full. Please try requesting test Ether again later.
+ </div>
+ );
+
+ case BalanceErrs.mintingFailed:
+ return (
+ <div>
+ Minting your test tokens failed unexpectedly. Please refresh the page and try again.
+ </div>
+ );
+
+ case BalanceErrs.wethConversionFailed:
+ return (
+ <div>
+ Converting between Ether and Ether Tokens failed unexpectedly.
+ Please refresh the page and try again.
+ </div>
+ );
+
+ case BalanceErrs.allowanceSettingFailed:
+ return (
+ <div>
+ An unexpected error occurred while trying to set your test token allowance.
+ {' '}Please refresh the page and try again.
+ </div>
+ );
+
+ case undefined:
+ return null; // No error to show
+
+ default:
+ throw utils.spawnSwitchErr('errorType', this.state.errorType);
+ }
+ }
+ private renderDharmaLoanFrame() {
+ if (utils.isUserOnMobile()) {
+ return (
+ <h4 style={{ textAlign: 'center' }}>
+ We apologize -- Dharma loan requests are not available on
+ mobile yet. Please try again through your desktop browser.
+ </h4>
+ );
+ } else {
+ return (
+ <DharmaLoanFrame
+ partner="0x"
+ env={utils.getCurrentEnvironment()}
+ screenWidth={this.props.screenWidth}
+ />
+ );
+ }
+ }
+ private onErrorOccurred(errorType: BalanceErrs) {
+ this.setState({
+ errorType,
+ });
+ }
+ private async onMintTestTokensAsync(token: Token): Promise<boolean> {
+ 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<boolean> {
+ 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<TopBarProps, TopBarState> {
+ public static defaultProps: Partial<TopBarProps> = {
+ 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 = [
+ <Link
+ key="subMenuItem-zeroEx"
+ to={WebsitePaths.ZeroExJs}
+ className="text-decoration-none"
+ >
+ <MenuItem style={{fontSize: styles.menuItem.fontSize}} primaryText="0x.js" />
+ </Link>,
+ <Link
+ key="subMenuItem-smartContracts"
+ to={WebsitePaths.SmartContracts}
+ className="text-decoration-none"
+ >
+ <MenuItem style={{fontSize: styles.menuItem.fontSize}} primaryText="Smart Contracts" />
+ </Link>,
+ <a
+ key="subMenuItem-standard-relayer-api"
+ target="_blank"
+ className="text-decoration-none"
+ href={constants.STANDARD_RELAYER_API_GITHUB}
+ >
+ <MenuItem style={{fontSize: styles.menuItem.fontSize}} primaryText="Standard Relayer API" />
+ </a>,
+ <a
+ key="subMenuItem-github"
+ target="_blank"
+ className="text-decoration-none"
+ href={constants.GITHUB_URL}
+ >
+ <MenuItem style={{ fontSize: styles.menuItem.fontSize }} primaryText="GitHub" />
+ </a>,
+ <a
+ key="subMenuItem-whitePaper"
+ target="_blank"
+ className="text-decoration-none"
+ href={`${WebsitePaths.Whitepaper}`}
+ >
+ <MenuItem style={{fontSize: styles.menuItem.fontSize}} primaryText="Whitepaper" />
+ </a>,
+ ];
+ const bottomBorderStyle = this.shouldDisplayBottomBar() ? styles.bottomBar : {};
+ const fullWithClassNames = isFullWidthPage ? 'pr4' : '';
+ const logoUrl = isNightVersion ? '/images/protocol_logo_white.png' : '/images/protocol_logo_black.png';
+ return (
+ <div style={{...styles.topBar, ...bottomBorderStyle, ...this.props.style}} className="pb1">
+ <div className={parentClassNames}>
+ <div className="col col-2 sm-pl2 md-pl2 lg-pl0" style={{paddingTop: 15}}>
+ <Link to={`${WebsitePaths.Home}`} className="text-decoration-none">
+ <img src={logoUrl} height="30" />
+ </Link>
+ </div>
+ <div className={`col col-${isFullWidthPage ? '8' : '9'} lg-hide md-hide`} />
+ <div className={`col col-${isFullWidthPage ? '6' : '5'} sm-hide xs-hide`} />
+ {!this.isViewingPortal() &&
+ <div className={`col col-${isFullWidthPage ? '4' : '5'} ${fullWithClassNames} lg-pr0 md-pr2 sm-hide xs-hide`}>
+ <div className="flex justify-between">
+ <DropDownMenuItem
+ title="Developers"
+ subMenuItems={developerSectionMenuItems}
+ style={styles.menuItem}
+ isNightVersion={isNightVersion}
+ />
+ <TopBarMenuItem
+ title="Wiki"
+ path={`${WebsitePaths.Wiki}`}
+ style={styles.menuItem}
+ isNightVersion={isNightVersion}
+ />
+ <TopBarMenuItem
+ title="About"
+ path={`${WebsitePaths.About}`}
+ style={styles.menuItem}
+ isNightVersion={isNightVersion}
+ />
+ <TopBarMenuItem
+ title="Portal DApp"
+ path={`${WebsitePaths.Portal}`}
+ isPrimary={true}
+ style={styles.menuItem}
+ className={`${isFullWidthPage && 'md-hide'}`}
+ isNightVersion={isNightVersion}
+ />
+ </div>
+ </div>
+ }
+ {this.props.blockchainIsLoaded && !_.isEmpty(this.props.userAddress) &&
+ <div className="col col-5">
+ {this.renderUser()}
+ </div>
+ }
+ {!this.isViewingPortal() &&
+ <div
+ className={`col ${isFullWidthPage ? 'col-2 pl2' : 'col-1'} md-hide lg-hide`}
+ >
+ <div
+ style={{fontSize: 25, color: isNightVersion ? 'white' : 'black', cursor: 'pointer', paddingTop: 16}}
+ >
+ <i
+ className="zmdi zmdi-menu"
+ onClick={this.onMenuButtonClick.bind(this)}
+ />
+ </div>
+ </div>
+ }
+ </div>
+ {this.renderDrawer()}
+ </div>
+ );
+ }
+ private renderDrawer() {
+ return (
+ <Drawer
+ open={this.state.isDrawerOpen}
+ docked={false}
+ openSecondary={true}
+ onRequestChange={this.onMenuButtonClick.bind(this)}
+ >
+ {this.renderPortalMenu()}
+ {this.renderDocsMenu()}
+ {this.renderWiki()}
+ <div className="pl1 py1 mt3" style={{backgroundColor: SECTION_HEADER_COLOR}}>Website</div>
+ <Link to={WebsitePaths.Home} className="text-decoration-none">
+ <MenuItem className="py2">Home</MenuItem>
+ </Link>
+ <Link to={`${WebsitePaths.Wiki}`} className="text-decoration-none">
+ <MenuItem className="py2">Wiki</MenuItem>
+ </Link>
+ {!this.isViewing0xjsDocs() &&
+ <Link to={WebsitePaths.ZeroExJs} className="text-decoration-none">
+ <MenuItem className="py2">0x.js Docs</MenuItem>
+ </Link>
+ }
+ {!this.isViewingSmartContractsDocs() &&
+ <Link to={WebsitePaths.SmartContracts} className="text-decoration-none">
+ <MenuItem className="py2">Smart Contract Docs</MenuItem>
+ </Link>
+ }
+ {!this.isViewingPortal() &&
+ <Link to={`${WebsitePaths.Portal}`} className="text-decoration-none">
+ <MenuItem className="py2">Portal DApp</MenuItem>
+ </Link>
+ }
+ <a
+ className="text-decoration-none"
+ target="_blank"
+ href={`${WebsitePaths.Whitepaper}`}
+ >
+ <MenuItem className="py2">Whitepaper</MenuItem>
+ </a>
+ <Link to={`${WebsitePaths.About}`} className="text-decoration-none">
+ <MenuItem className="py2">About</MenuItem>
+ </Link>
+ <a
+ className="text-decoration-none"
+ target="_blank"
+ href={constants.BLOG_URL}
+ >
+ <MenuItem className="py2">Blog</MenuItem>
+ </a>
+ <Link to={`${WebsitePaths.FAQ}`} className="text-decoration-none">
+ <MenuItem
+ className="py2"
+ onTouchTap={this.onMenuButtonClick.bind(this)}
+ >
+ FAQ
+ </MenuItem>
+ </Link>
+ </Drawer>
+ );
+ }
+ 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 (
+ <div className="lg-hide md-hide">
+ <div className="pl1 py1" style={{backgroundColor: SECTION_HEADER_COLOR}}>{sectionTitle}</div>
+ <NestedSidebarMenu
+ topLevelMenu={topLevelMenu}
+ menuSubsectionsBySection={this.props.menuSubsectionsBySection}
+ shouldDisplaySectionHeaders={false}
+ onMenuItemClick={this.onMenuButtonClick.bind(this)}
+ selectedVersion={this.props.docsVersion}
+ doc={this.props.doc}
+ versions={this.props.availableDocVersions}
+ />
+ </div>
+ );
+ }
+ private renderWiki() {
+ if (!this.isViewingWiki()) {
+ return;
+ }
+
+ return (
+ <div className="lg-hide md-hide">
+ <div className="pl1 py1" style={{backgroundColor: SECTION_HEADER_COLOR}}>0x Protocol Wiki</div>
+ <NestedSidebarMenu
+ topLevelMenu={this.props.menuSubsectionsBySection}
+ menuSubsectionsBySection={this.props.menuSubsectionsBySection}
+ shouldDisplaySectionHeaders={false}
+ onMenuItemClick={this.onMenuButtonClick.bind(this)}
+ />
+ </div>
+ );
+ }
+ private renderPortalMenu() {
+ if (!this.isViewingPortal()) {
+ return;
+ }
+
+ return (
+ <div className="lg-hide md-hide">
+ <div className="pl1 py1" style={{backgroundColor: SECTION_HEADER_COLOR}}>Portal DApp</div>
+ <PortalMenu
+ menuItemStyle={{color: 'black'}}
+ onClick={this.onMenuButtonClick.bind(this)}
+ />
+ </div>
+ );
+ }
+ private renderUser() {
+ const userAddress = this.props.userAddress;
+ const identiconDiameter = 26;
+ return (
+ <div
+ className="flex right lg-pr0 md-pr2 sm-pr2"
+ style={{paddingTop: 16}}
+ >
+ <div
+ style={styles.address}
+ data-tip={true}
+ data-for="userAddressTooltip"
+ >
+ {!_.isEmpty(userAddress) ? userAddress : ''}
+ </div>
+ <ReactTooltip id="userAddressTooltip">{userAddress}</ReactTooltip>
+ <div>
+ <Identicon address={userAddress} diameter={identiconDiameter} />
+ </div>
+ </div>
+ );
+ }
+ 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<TopBarMenuItemProps, TopBarMenuItemState> {
+ public static defaultProps: Partial<TopBarMenuItemProps> = {
+ 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 (
+ <div
+ className={`center ${this.props.className}`}
+ style={{...this.props.style, ...primaryStyles, color: menuItemColor}}
+ >
+ <Link to={this.props.path} className="text-decoration-none" style={{color: linkColor}}>
+ {this.props.title}
+ </Link>
+ </div>
+ );
+ }
+}
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<TrackTokenConfirmationProps, TrackTokenConfirmationState> {
+ public render() {
+ const isMultipleTokens = this.props.tokens.length > 1;
+ const allTokens = _.values(this.props.tokenByAddress);
+ return (
+ <div style={{color: colors.grey700}}>
+ {this.props.isAddingTokenToTracked ?
+ <div className="py4 my4 center">
+ <span className="pr1">
+ <i className="zmdi zmdi-spinner zmdi-hc-spin" />
+ </span>
+ <span>Adding token{isMultipleTokens && 's'}...</span>
+ </div> :
+ <div>
+ <div>
+ You do not currently track the following token{isMultipleTokens && 's'}:
+ </div>
+ <div className="py2 clearfix mx-auto center" style={{width: 355}}>
+ {_.map(this.props.tokens, (token: Token) => (
+ <div
+ key={`token-profile-${token.name}`}
+ className={`col col-${isMultipleTokens ? '6' : '12'} px2`}
+ >
+ <Party
+ label={token.name}
+ address={token.address}
+ networkId={this.props.networkId}
+ alternativeImage={token.iconUrl}
+ isInTokenRegistry={token.isRegistered}
+ hasUniqueNameAndSymbol={utils.hasUniqueNameAndSymbol(allTokens, token)}
+ />
+ </div>
+ ))}
+ </div>
+ <div>
+ 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?
+ </div>
+ </div>
+ }
+ </div>
+ );
+ }
+}
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<TradeHistoryProps, TradeHistoryState> {
+ 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 (
+ <div className="lg-px4 md-px4 sm-px2">
+ <h3>Trade history</h3>
+ <Divider />
+ <div className="pt2" style={{height: 608, overflow: 'scroll'}}>
+ {this.renderTrades()}
+ </div>
+ </div>
+ );
+ }
+ private renderTrades() {
+ const numNonCustomFills = this.numFillsWithoutCustomERC20Tokens();
+ if (numNonCustomFills === 0) {
+ return this.renderEmptyNotice();
+ }
+
+ return _.map(this.state.sortedFills, (fill, index) => {
+ return (
+ <TradeHistoryItem
+ key={`${fill.orderHash}-${fill.filledTakerTokenAmount}-${index}`}
+ fill={fill}
+ tokenByAddress={this.props.tokenByAddress}
+ userAddress={this.props.userAddress}
+ networkId={this.props.networkId}
+ />
+ );
+ });
+ }
+ private renderEmptyNotice() {
+ return (
+ <Paper className="mt1 p2 mx-auto center" style={{width: '80%'}}>
+ No filled orders yet.
+ </Paper>
+ );
+ }
+ 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<TradeHistoryItemProps, TradeHistoryItemState> {
+ 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 (
+ <Paper
+ className="py1"
+ style={{margin: '3px 3px 15px 3px'}}
+ >
+ <div className="clearfix">
+ <div className="col col-12 lg-col-1 md-col-1 pt2 lg-pl3 md-pl3">
+ {this.renderDate()}
+ </div>
+ <div
+ className="col col-12 lg-col-6 md-col-6 lg-pl3 md-pl3"
+ style={{fontSize: 12, fontWeight: 100}}
+ >
+ <div className="flex sm-mx-auto xs-mx-auto" style={{paddingTop: 4, width: 224}}>
+ <Party
+ label="Maker"
+ address={fill.maker}
+ identiconDiameter={IDENTICON_DIAMETER}
+ networkId={this.props.networkId}
+ />
+ <i style={{fontSize: 30}} className="zmdi zmdi-swap py3" />
+ <Party
+ label="Taker"
+ address={fill.taker}
+ identiconDiameter={IDENTICON_DIAMETER}
+ networkId={this.props.networkId}
+ />
+ </div>
+ </div>
+ <div
+ className={amountColClassNames}
+ style={amountColStyle}
+ >
+ {this.renderAmounts(makerToken, takerToken)}
+ </div>
+ <div className="col col-12 lg-col-1 md-col-1 lg-pr3 md-pr3 lg-py3 md-py3 sm-pb1 sm-center">
+ <div className="pt1 lg-right md-right sm-mx-auto" style={{width: 13}}>
+ <EtherScanIcon
+ addressOrTxHash={fill.transactionHash}
+ networkId={this.props.networkId}
+ etherscanLinkSuffixes={EtherscanLinkSuffixes.tx}
+ />
+ </div>
+ </div>
+ </div>
+ </Paper>
+ );
+ }
+ 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 (
+ <div>
+ <div
+ style={{color: colors.green400, fontSize: 16}}
+ >
+ <span>+{' '}</span>
+ {this.renderAmount(receiveAmount, receiveToken.symbol, receiveToken.decimals)}
+ </div>
+ <div
+ className="pb1 inline-block"
+ style={{color: colors.red200, fontSize: 16}}
+ >
+ <span>-{' '}</span>
+ {this.renderAmount(givenAmount, givenToken.symbol, givenToken.decimals)}
+ </div>
+ <div style={{color: colors.grey400, fontSize: 14}}>
+ {exchangeRate.toFixed(PRECISION)} {givenToken.symbol}/{receiveToken.symbol}
+ </div>
+ </div>
+ );
+ }
+ 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 (
+ <div
+ data-tip={true}
+ data-for={dateTooltipId}
+ >
+ <div className="center pt1" style={{fontSize: 13}}>{monthAbreviation}</div>
+ <div className="center" style={{fontSize: 24, fontWeight: 100}}>{dayOfMonth}</div>
+ <ReactTooltip id={dateTooltipId}>{formattedBlockDate}</ReactTooltip>
+ </div>
+ );
+ }
+ private renderAmount(amount: BigNumber, symbol: string, decimals: number) {
+ const unitAmount = ZeroEx.toUnitAmount(amount, decimals);
+ return (
+ <span>
+ {unitAmount.toFixed(PRECISION)} {symbol}
+ </span>
+ );
+ }
+}
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 (
+ <div className="rounded center" style={errMsgStyles}>
+ {props.message}
+ </div>
+ );
+}
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<BadgeProps, BadgeState> {
+ 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 (
+ <div
+ className="p1 center"
+ style={badgeStyle}
+ onMouseOver={this.setHoverState.bind(this, true)}
+ onMouseOut={this.setHoverState.bind(this, false)}
+ >
+ {this.props.title}
+ </div>
+ );
+ }
+ 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<CopyIconProps, CopyIconState> {
+ 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 (
+ <div className="inline-block">
+ <CopyToClipboard text={this.props.data} onCopy={this.onCopy.bind(this)}>
+ <div
+ className="inline flex"
+ style={{cursor: 'pointer', color: colors.amber600}}
+ ref={this.setRefToProperty.bind(this)}
+ data-tip={true}
+ data-for="copy"
+ data-event="click"
+ data-iscapture={true} // This let's the click event continue to propogate
+ onMouseOver={this.setHoverState.bind(this, true)}
+ onMouseOut={this.setHoverState.bind(this, false)}
+ >
+ <div>
+ <i style={{fontSize: 15}} className="zmdi zmdi-copy" />
+ </div>
+ {this.props.callToAction &&
+ <div className="pl1">{this.props.callToAction}</div>
+ }
+ </div>
+ </CopyToClipboard>
+ <ReactTooltip id="copy">Copied!</ReactTooltip>
+ </div>
+ );
+ }
+ 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<DropDownMenuItemProps, DropDownMenuItemState> {
+ public static defaultProps: Partial<DropDownMenuItemProps> = {
+ 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 (
+ <div
+ style={{...this.props.style, color: colorStyle}}
+ onMouseEnter={this.onHover.bind(this)}
+ onMouseLeave={this.onHoverOff.bind(this)}
+ >
+ <div className="flex relative">
+ <div style={{paddingRight: 10}}>
+ {this.props.title}
+ </div>
+ <div className="absolute" style={{paddingLeft: 3, right: 3, top: -2}}>
+ <i className="zmdi zmdi-caret-right" style={{fontSize: 22}} />
+ </div>
+ </div>
+ <Popover
+ open={this.state.isDropDownOpen}
+ anchorEl={this.state.anchorEl}
+ anchorOrigin={{horizontal: 'middle', vertical: 'bottom'}}
+ targetOrigin={{horizontal: 'middle', vertical: 'top'}}
+ onRequestClose={this.closePopover.bind(this)}
+ useLayerForClickAway={false}
+ >
+ <div
+ onMouseEnter={this.onHover.bind(this)}
+ onMouseLeave={this.onHoverOff.bind(this)}
+ >
+ <Menu style={{color: CUSTOM_LIGHT_GRAY}}>
+ {this.props.subMenuItems}
+ </Menu>
+ </div>
+ </Popover>
+ </div>
+ );
+ }
+ private onHover(event: React.FormEvent<HTMLInputElement>) {
+ this.isHovering = true;
+ this.checkIfShouldOpenPopover(event);
+ }
+ private checkIfShouldOpenPopover(event: React.FormEvent<HTMLInputElement>) {
+ if (this.state.isDropDownOpen) {
+ return; // noop
+ }
+
+ this.setState({
+ isDropDownOpen: true,
+ anchorEl: event.currentTarget,
+ });
+ }
+ private onHoverOff(event: React.FormEvent<HTMLInputElement>) {
+ 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 (
+ <div>
+ <div
+ className="inline"
+ style={{fontSize: 13}}
+ data-tip={true}
+ data-for={tooltipId}
+ >
+ {truncatedAddress}
+ </div>
+ <div className="pl1 inline">
+ <EtherScanIcon
+ addressOrTxHash={props.address}
+ networkId={props.networkId}
+ etherscanLinkSuffixes={EtherscanLinkSuffixes.address}
+ />
+ </div>
+ <ReactTooltip id={tooltipId}>{props.address}</ReactTooltip>
+ </div>
+ );
+};
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 (
+ <div className="inline">
+ {!_.isUndefined(etherscanLinkIfExists) ?
+ <a
+ href={etherscanLinkIfExists}
+ target="_blank"
+ >
+ {renderIcon()}
+ </a> :
+ <div
+ className="inline"
+ data-tip={true}
+ data-for={transactionTooltipId}
+ >
+ {renderIcon()}
+ <ReactTooltip id={transactionTooltipId}>
+ Your network (id: {props.networkId}) is not supported by Etherscan
+ </ReactTooltip>
+ </div>
+ }
+ </div>
+ );
+};
+
+function renderIcon() {
+ return (
+ <i
+ style={{color: colors.amber600}}
+ className="zmdi zmdi-open-in-new"
+ />
+ );
+}
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 (
+ <div className="relative">
+ {props.label !== '' && <InputLabel text={props.label} />}
+ <div className="pb2" style={{height: 23}}>
+ {props.children}
+ </div>
+ <hr style={styles.hr} />
+ </div>
+ );
+}
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<FlashMessageProps, FlashMessageState> {
+ public static defaultProps: Partial<FlashMessageProps> = {
+ showDurationMs: SHOW_DURATION_MS,
+ bodyStyle: {},
+ };
+ public render() {
+ if (!_.isUndefined(this.props.flashMessage)) {
+ return (
+ <Snackbar
+ open={true}
+ message={this.props.flashMessage}
+ autoHideDuration={this.props.showDurationMs}
+ onRequestClose={this.onClose.bind(this)}
+ bodyStyle={this.props.bodyStyle}
+ />
+ );
+ } 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 (
+ <div
+ style={{...props.style}}
+ className="inline-block"
+ data-tip={props.explanation}
+ data-for="helpTooltip"
+ data-multiline={true}
+ >
+ <i style={{fontSize: 16}} className="zmdi zmdi-help" />
+ <ReactTooltip id="helpTooltip" />
+ </div>
+ );
+};
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<IdenticonProps, IdenticonState> {
+ public static defaultProps: Partial<IdenticonProps> = {
+ 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 (
+ <div
+ className="circle mx-auto relative transitionFix"
+ style={{width: diameter, height: diameter, overflow: 'hidden', ...this.props.style}}
+ >
+ <img src={icon.toDataURL()} style={{width: diameter, height: diameter, imageRendering: 'pixelated'}}/>
+ </div>
+ );
+ }
+}
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 (
+ <label style={styles.label}>{props.text}</label>
+ );
+};
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<boolean>;
+ onRightLabelClickAsync: () => Promise<boolean>;
+}
+
+interface LabeledSwitcherState {
+ isLeftSelected: boolean;
+}
+
+export class LabeledSwitcher extends React.Component<LabeledSwitcherProps, LabeledSwitcherState> {
+ constructor(props: LabeledSwitcherProps) {
+ super(props);
+ this.state = {
+ isLeftSelected: props.isLeftInitiallySelected,
+ };
+ }
+ public render() {
+ const isLeft = true;
+ return (
+ <div
+ className="rounded clearfix"
+ >
+ {this.renderLabel(this.props.labelLeft, isLeft, this.state.isLeftSelected)}
+ {this.renderLabel(this.props.labelRight, !isLeft, !this.state.isLeftSelected)}
+ </div>
+ );
+ }
+ 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 (
+ <div
+ className={`col col-6 center p1 ${isLeft ? 'rounded-left' : 'rounded-right'}`}
+ style={style}
+ onClick={this.onLabelClickAsync.bind(this, isLeft)}
+ >
+ {title}
+ </div>
+ );
+ }
+ private async onLabelClickAsync(isLeft: boolean): Promise<void> {
+ 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<LifeCycleRaisedButtonProps, LifeCycleRaisedButtonState> {
+ public static defaultProps: Partial<LifeCycleRaisedButtonProps> = {
+ 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 <span />;
+ }
+
+ 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 (
+ <RaisedButton
+ primary={this.props.isPrimary}
+ label={label}
+ style={{width: '100%'}}
+ backgroundColor={this.props.backgroundColor}
+ labelColor={this.props.labelColor}
+ onTouchTap={this.onClickAsync.bind(this)}
+ disabled={this.props.isDisabled || this.state.buttonState !== ButtonState.READY}
+ />
+ );
+ }
+ 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<LoadingProps, LoadingState> {
+ public render() {
+ return (
+ <div className="pt4 sm-px2 sm-pt2 sm-m1" style={{height: 500}}>
+ <Paper className="mx-auto" style={{maxWidth: 400}}>
+ {utils.isUserOnMobile() ?
+ <img className="p1" src="/gifs/0xAnimation.gif" width="96%" /> :
+ <div style={{pointerEvents: 'none'}}>
+ <Video
+ autoPlay={true}
+ loop={true}
+ muted={true}
+ controls={[]}
+ poster="/images/loading_poster.png"
+ >
+ <source src="/videos/0xAnimation.mp4" type="video/mp4" />
+ </Video>
+ </div>
+ }
+ <div className="center pt2" style={{paddingBottom: 11}}>Connecting to the blockchain...</div>
+ </Paper>
+ </div>
+ );
+ }
+}
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<MenuItemProps, MenuItemState> {
+ public static defaultProps: Partial<MenuItemProps> = {
+ 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 (
+ <Link to={this.props.to} style={{textDecoration: 'none', ...this.props.style}}>
+ <div
+ onClick={this.props.onClick.bind(this)}
+ className={`mx-auto ${this.props.className}`}
+ style={menuItemStyles}
+ onMouseEnter={this.onToggleHover.bind(this, true)}
+ onMouseLeave={this.onToggleHover.bind(this, false)}
+ >
+ {this.props.children}
+ </div>
+ </Link>
+ );
+ }
+ 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<PartyProps, PartyState> {
+ public static defaultProps: Partial<PartyProps> = {
+ 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 (
+ <div style={{overflow: 'hidden'}}>
+ <div className="pb1 center">{label}</div>
+ {_.isEmpty(address) ?
+ <div
+ className="circle mx-auto"
+ style={emptyIdenticonStyles}
+ /> :
+ <a
+ href={etherscanLinkIfExists}
+ target="_blank"
+ >
+ {isRegistered && !_.isUndefined(this.props.alternativeImage) ?
+ <img
+ style={tokenImageStyle}
+ src={this.props.alternativeImage}
+ /> :
+ <div
+ className="mx-auto"
+ style={{height: IMAGE_DIMENSION, width: IMAGE_DIMENSION}}
+ >
+ <Identicon
+ address={this.props.address}
+ diameter={identiconDiameter}
+ style={this.props.identiconStyle}
+ />
+ </div>
+ }
+ </a>
+ }
+ <div
+ className="mx-auto center pt1"
+ >
+ <div style={{height: 25}}>
+ <EthereumAddress address={address} networkId={this.props.networkId} />
+ </div>
+ {!_.isUndefined(this.props.isInTokenRegistry) &&
+ <div>
+ <div
+ data-tip={true}
+ data-for={registeredTooltipId}
+ className="mx-auto"
+ style={{fontSize: 13, width: 127}}
+ >
+ <span style={{color: isRegistered ? CHECK_MARK_GREEN : colors.red500}}>
+ <i
+ className={`zmdi ${isRegistered ? 'zmdi-check-circle' : 'zmdi-alert-triangle'}`}
+ />
+ </span>{' '}
+ <span>{isRegistered ? 'Registered' : 'Unregistered'} token</span>
+ <ReactTooltip id={registeredTooltipId}>
+ {isRegistered ?
+ <div>
+ This token address was found in the token registry<br />
+ smart contract and is therefore believed to be a<br />
+ legitimate token.
+ </div> :
+ <div>
+ This token is not included in the token registry<br />
+ smart contract. We cannot guarantee the legitimacy<br />
+ of this token. Make sure to verify its address on Etherscan.
+ </div>
+ }
+ </ReactTooltip>
+ </div>
+ </div>
+ }
+ {!_.isUndefined(this.props.hasUniqueNameAndSymbol) && !this.props.hasUniqueNameAndSymbol &&
+ <div>
+ <div
+ data-tip={true}
+ data-for={uniqueNameAndSymbolTooltipId}
+ className="mx-auto"
+ style={{fontSize: 13, width: 127}}
+ >
+ <span style={{color: colors.red500}}>
+ <i
+ className="zmdi zmdi-alert-octagon"
+ />
+ </span>{' '}
+ <span>Suspicious token</span>
+ <ReactTooltip id={uniqueNameAndSymbolTooltipId}>
+ This token shares it's name, symbol or both with<br />
+ a token in the 0x Token Registry but it has a different<br />
+ smart contract address. This is most likely a scam token!
+ </ReactTooltip>
+ </div>
+ </div>
+ }
+ </div>
+ </div>
+ );
+ }
+}
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 (
+ <span>
+ {props.label}
+ <span style={{color: colors.red600}}>*</span>
+ </span>
+ );
+};
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 (
+ <div className="mx-auto pt3" style={{maxWidth: 400, height: 409}}>
+ <div
+ className="relative"
+ style={{top: '50%', transform: 'translateY(-50%)', height: 95}}
+ >
+ <CircularProgress />
+ <div className="pt3 pb3">
+ {props.message}
+ </div>
+ </div>
+ </div>
+ );
+};
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<SwapIconProps, SwapIconState> {
+ 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 (
+ <div
+ className="mx-auto pt4"
+ style={{cursor: 'pointer', height: 50, width: 37.5}}
+ onClick={this.props.swapTokensFn}
+ onMouseEnter={this.onToggleHover.bind(this, true)}
+ onMouseLeave={this.onToggleHover.bind(this, false)}
+ >
+ <i
+ style={swapStyles}
+ className="zmdi zmdi-swap"
+ />
+ </div>
+ );
+ }
+ 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<TokenIconProps, TokenIconState> {
+ public render() {
+ const token = this.props.token;
+ const diameter = this.props.diameter;
+ return (
+ <div>
+ {(token.isRegistered && !_.isUndefined(token.iconUrl)) ?
+ <img
+ style={{width: diameter, height: diameter}}
+ src={token.iconUrl}
+ /> :
+ <Identicon address={token.address} diameter={diameter} />
+ }
+ </div>
+ );
+ }
+}
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<VisualOrderProps, VisualOrderState> {
+ public render() {
+ const allTokens = _.values(this.props.tokenByAddress);
+ const makerImage = this.props.makerToken.iconUrl;
+ const takerImage = this.props.takerToken.iconUrl;
+ return (
+ <div>
+ <div className="clearfix">
+ <div className="col col-5 center">
+ <Party
+ label="Send"
+ address={this.props.takerToken.address}
+ alternativeImage={takerImage}
+ networkId={this.props.networkId}
+ isInTokenRegistry={this.props.isTakerTokenAddressInRegistry}
+ hasUniqueNameAndSymbol={utils.hasUniqueNameAndSymbol(allTokens, this.props.takerToken)}
+ />
+ </div>
+ <div className="col col-2 center pt1">
+ <div className="pb1">
+ {this.renderAmount(this.props.takerAssetToken, this.props.takerToken)}
+ </div>
+ <div className="lg-p2 md-p2 sm-p1">
+ <img src="/images/trade_arrows.png" style={{width: 47}} />
+ </div>
+ <div className="pt1">
+ {this.renderAmount(this.props.makerAssetToken, this.props.makerToken)}
+ </div>
+ </div>
+ <div className="col col-5 center">
+ <Party
+ label="Receive"
+ address={this.props.makerToken.address}
+ alternativeImage={makerImage}
+ networkId={this.props.networkId}
+ isInTokenRegistry={this.props.isMakerTokenAddressInRegistry}
+ hasUniqueNameAndSymbol={utils.hasUniqueNameAndSymbol(allTokens, this.props.makerToken)}
+ />
+ </div>
+ </div>
+ </div>
+ );
+ }
+ private renderAmount(assetToken: AssetToken, token: Token) {
+ const unitAmount = ZeroEx.toUnitAmount(assetToken.amount, token.decimals);
+ return (
+ <div style={{fontSize: 13}}>
+ {unitAmount.toNumber().toFixed(PRECISION)} {token.symbol}
+ </div>
+ );
+ }
+}