diff options
author | Leonid <logvinov.leon@gmail.com> | 2017-11-28 05:53:21 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-11-28 05:53:21 +0800 |
commit | cf0a8b2d0503f9d08ac789024d44b196613ecdc3 (patch) | |
tree | 75653154b85dd6df06da46af9411baf57c23367b /packages/website/ts | |
parent | 37f0051d8380279a1a882cdc724e54fc09117901 (diff) | |
parent | 4a17f5e82074b01e74ae6982e82419a037eebdb4 (diff) | |
download | dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar.gz dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar.bz2 dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar.lz dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar.xz dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.tar.zst dexon-sol-tools-cf0a8b2d0503f9d08ac789024d44b196613ecdc3.zip |
Merge branch 'development' into feature/passNetworkId
Diffstat (limited to 'packages/website/ts')
113 files changed, 16301 insertions, 0 deletions
diff --git a/packages/website/ts/blockchain.ts b/packages/website/ts/blockchain.ts new file mode 100644 index 000000000..b13c48a65 --- /dev/null +++ b/packages/website/ts/blockchain.ts @@ -0,0 +1,787 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { + ZeroEx, + ZeroExError, + ExchangeContractErrs, + ExchangeContractEventArgs, + ExchangeEvents, + SubscriptionOpts, + IndexedFilterValues, + DecodedLogEvent, + BlockParam, + LogFillContractEventArgs, + LogCancelContractEventArgs, + Token as ZeroExToken, + LogWithDecodedArgs, + TransactionReceiptWithDecodedLogs, + SignedOrder, + Order, +} from '0x.js'; +import BigNumber from 'bignumber.js'; +import Web3 = require('web3'); +import promisify = require('es6-promisify'); +import findVersions = require('find-versions'); +import compareVersions = require('compare-versions'); +import contract = require('truffle-contract'); +import ethUtil = require('ethereumjs-util'); +import ProviderEngine = require('web3-provider-engine'); +import FilterSubprovider = require('web3-provider-engine/subproviders/filters'); +import { TransactionSubmitted } from 'ts/components/flash_messages/transaction_submitted'; +import {TokenSendCompleted} from 'ts/components/flash_messages/token_send_completed'; +import {RedundantRPCSubprovider} from 'ts/subproviders/redundant_rpc_subprovider'; +import {InjectedWeb3SubProvider} from 'ts/subproviders/injected_web3_subprovider'; +import {ledgerWalletSubproviderFactory} from 'ts/subproviders/ledger_wallet_subprovider_factory'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import { + BlockchainErrs, + Token, + SignatureData, + Side, + ContractResponse, + BlockchainCallErrs, + ContractInstance, + ProviderType, + LedgerWalletSubprovider, + EtherscanLinkSuffixes, + TokenByAddress, + TokenStateByAddress, +} from 'ts/types'; +import {Web3Wrapper} from 'ts/web3_wrapper'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {tradeHistoryStorage} from 'ts/local_storage/trade_history_storage'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; +import * as MintableArtifacts from '../contracts/Mintable.json'; + +const ALLOWANCE_TO_ZERO_GAS_AMOUNT = 45730; +const BLOCK_NUMBER_BACK_TRACK = 50; + +export class Blockchain { + public networkId: number; + public nodeVersion: string; + private zeroEx: ZeroEx; + private dispatcher: Dispatcher; + private web3Wrapper: Web3Wrapper; + private exchangeAddress: string; + private tokenTransferProxy: ContractInstance; + private tokenRegistry: ContractInstance; + private userAddress: string; + private cachedProvider: Web3.Provider; + private ledgerSubProvider: LedgerWalletSubprovider; + private zrxPollIntervalId: number; + constructor(dispatcher: Dispatcher, isSalePage: boolean = false) { + this.dispatcher = dispatcher; + this.userAddress = ''; + this.onPageLoadInitFireAndForgetAsync(); + } + public async networkIdUpdatedFireAndForgetAsync(newNetworkId: number) { + const isConnected = !_.isUndefined(newNetworkId); + if (!isConnected) { + this.networkId = newNetworkId; + this.dispatcher.encounteredBlockchainError(BlockchainErrs.DISCONNECTED_FROM_ETHEREUM_NODE); + this.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + } else if (this.networkId !== newNetworkId) { + this.networkId = newNetworkId; + this.dispatcher.encounteredBlockchainError(''); + await this.fetchTokenInformationAsync(); + await this.rehydrateStoreWithContractEvents(); + } + } + public async userAddressUpdatedFireAndForgetAsync(newUserAddress: string) { + if (this.userAddress !== newUserAddress) { + this.userAddress = newUserAddress; + await this.fetchTokenInformationAsync(); + await this.rehydrateStoreWithContractEvents(); + } + } + public async nodeVersionUpdatedFireAndForgetAsync(nodeVersion: string) { + if (this.nodeVersion !== nodeVersion) { + this.nodeVersion = nodeVersion; + } + } + public async isAddressInTokenRegistryAsync(tokenAddress: string): Promise<boolean> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + const tokenIfExists = await this.zeroEx.tokenRegistry.getTokenIfExistsAsync(tokenAddress); + return !_.isUndefined(tokenIfExists); + } + public getLedgerDerivationPathIfExists(): string { + if (_.isUndefined(this.ledgerSubProvider)) { + return undefined; + } + const path = this.ledgerSubProvider.getPath(); + return path; + } + public updateLedgerDerivationPathIfExists(path: string) { + if (_.isUndefined(this.ledgerSubProvider)) { + return; // noop + } + this.ledgerSubProvider.setPath(path); + } + public updateLedgerDerivationIndex(pathIndex: number) { + if (_.isUndefined(this.ledgerSubProvider)) { + return; // noop + } + this.ledgerSubProvider.setPathIndex(pathIndex); + } + public async providerTypeUpdatedFireAndForgetAsync(providerType: ProviderType) { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + // Should actually be Web3.Provider|ProviderEngine union type but it causes issues + // later on in the logic. + let provider; + switch (providerType) { + case ProviderType.LEDGER: { + const isU2FSupported = await utils.isU2FSupportedAsync(); + if (!isU2FSupported) { + throw new Error('Cannot update providerType to LEDGER without U2F support'); + } + + // Cache injected provider so that we can switch the user back to it easily + this.cachedProvider = this.web3Wrapper.getProviderObj(); + + this.dispatcher.updateUserAddress(''); // Clear old userAddress + + provider = new ProviderEngine(); + this.ledgerSubProvider = ledgerWalletSubproviderFactory(this.getBlockchainNetworkId.bind(this)); + provider.addProvider(this.ledgerSubProvider); + provider.addProvider(new FilterSubprovider()); + const networkId = configs.isMainnetEnabled ? + constants.MAINNET_NETWORK_ID : + constants.TESTNET_NETWORK_ID; + provider.addProvider(new RedundantRPCSubprovider( + constants.PUBLIC_NODE_URLS_BY_NETWORK_ID[networkId], + )); + provider.start(); + this.web3Wrapper.destroy(); + const shouldPollUserAddress = false; + this.web3Wrapper = new Web3Wrapper(this.dispatcher, provider, this.networkId, shouldPollUserAddress); + await this.zeroEx.setProviderAsync(provider); + await this.postInstantiationOrUpdatingProviderZeroExAsync(); + break; + } + + case ProviderType.INJECTED: { + if (_.isUndefined(this.cachedProvider)) { + return; // Going from injected to injected, so we noop + } + provider = this.cachedProvider; + const shouldPollUserAddress = true; + this.web3Wrapper = new Web3Wrapper(this.dispatcher, provider, this.networkId, shouldPollUserAddress); + await this.zeroEx.setProviderAsync(provider); + await this.postInstantiationOrUpdatingProviderZeroExAsync(); + delete this.ledgerSubProvider; + delete this.cachedProvider; + break; + } + + default: + throw utils.spawnSwitchErr('providerType', providerType); + } + + await this.fetchTokenInformationAsync(); + } + public async setProxyAllowanceAsync(token: Token, amountInBaseUnits: BigNumber): Promise<void> { + utils.assert(this.isValidAddress(token.address), BlockchainCallErrs.TOKEN_ADDRESS_IS_INVALID); + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + + const txHash = await this.zeroEx.token.setProxyAllowanceAsync( + token.address, this.userAddress, amountInBaseUnits, + ); + await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + const allowance = amountInBaseUnits; + this.dispatcher.replaceTokenAllowanceByAddress(token.address, allowance); + } + public async transferAsync(token: Token, toAddress: string, + amountInBaseUnits: BigNumber): Promise<void> { + const txHash = await this.zeroEx.token.transferAsync( + token.address, this.userAddress, toAddress, amountInBaseUnits, + ); + await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + const etherScanLinkIfExists = utils.getEtherScanLinkIfExists(txHash, this.networkId, EtherscanLinkSuffixes.tx); + this.dispatcher.showFlashMessage(React.createElement(TokenSendCompleted, { + etherScanLinkIfExists, + token, + toAddress, + amountInBaseUnits, + })); + } + public portalOrderToSignedOrder(maker: string, taker: string, makerTokenAddress: string, + takerTokenAddress: string, makerTokenAmount: BigNumber, + takerTokenAmount: BigNumber, makerFee: BigNumber, + takerFee: BigNumber, expirationUnixTimestampSec: BigNumber, + feeRecipient: string, + signatureData: SignatureData, salt: BigNumber): SignedOrder { + const ecSignature = signatureData; + const exchangeContractAddress = this.getExchangeContractAddressIfExists(); + taker = _.isEmpty(taker) ? constants.NULL_ADDRESS : taker; + const signedOrder = { + ecSignature, + exchangeContractAddress, + expirationUnixTimestampSec, + feeRecipient, + maker, + makerFee, + makerTokenAddress, + makerTokenAmount, + salt, + taker, + takerFee, + takerTokenAddress, + takerTokenAmount, + }; + return signedOrder; + } + public async fillOrderAsync(signedOrder: SignedOrder, + fillTakerTokenAmount: BigNumber): Promise<BigNumber> { + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + const shouldThrowOnInsufficientBalanceOrAllowance = true; + + const txHash = await this.zeroEx.exchange.fillOrderAsync( + signedOrder, fillTakerTokenAmount, shouldThrowOnInsufficientBalanceOrAllowance, this.userAddress, + ); + const receipt = await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + const logs: Array<LogWithDecodedArgs<ExchangeContractEventArgs>> = receipt.logs as any; + this.zeroEx.exchange.throwLogErrorsAsErrors(logs); + const logFill = _.find(logs, {event: 'LogFill'}); + const args = logFill.args as any as LogFillContractEventArgs; + const filledTakerTokenAmount = args.filledTakerTokenAmount; + return filledTakerTokenAmount; + } + public async cancelOrderAsync(signedOrder: SignedOrder, + cancelTakerTokenAmount: BigNumber): Promise<BigNumber> { + const txHash = await this.zeroEx.exchange.cancelOrderAsync( + signedOrder, cancelTakerTokenAmount, + ); + const receipt = await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + const logs: Array<LogWithDecodedArgs<ExchangeContractEventArgs>> = receipt.logs as any; + this.zeroEx.exchange.throwLogErrorsAsErrors(logs); + const logCancel = _.find(logs, {event: ExchangeEvents.LogCancel}); + const args = logCancel.args as any as LogCancelContractEventArgs; + const cancelledTakerTokenAmount = args.cancelledTakerTokenAmount; + return cancelledTakerTokenAmount; + } + public async getUnavailableTakerAmountAsync(orderHash: string): Promise<BigNumber> { + utils.assert(ZeroEx.isValidOrderHash(orderHash), 'Must be valid orderHash'); + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + const unavailableTakerAmount = await this.zeroEx.exchange.getUnavailableTakerAmountAsync(orderHash); + return unavailableTakerAmount; + } + public getExchangeContractAddressIfExists() { + return this.exchangeAddress; + } + public toHumanReadableErrorMsg(error: ZeroExError|ExchangeContractErrs, takerAddress: string): string { + const ZeroExErrorToHumanReadableError: {[error: string]: string} = { + [ZeroExError.ContractDoesNotExist]: 'Contract does not exist', + [ZeroExError.ExchangeContractDoesNotExist]: 'Exchange contract does not exist', + [ZeroExError.UnhandledError]: ' Unhandled error occured', + [ZeroExError.UserHasNoAssociatedAddress]: 'User has no addresses available', + [ZeroExError.InvalidSignature]: 'Order signature is not valid', + [ZeroExError.ContractNotDeployedOnNetwork]: 'Contract is not deployed on the detected network', + [ZeroExError.InvalidJump]: 'Invalid jump occured while executing the transaction', + [ZeroExError.OutOfGas]: 'Transaction ran out of gas', + [ZeroExError.NoNetworkId]: 'No network id detected', + }; + const exchangeContractErrorToHumanReadableError: {[error: string]: string} = { + [ExchangeContractErrs.OrderFillExpired]: 'This order has expired', + [ExchangeContractErrs.OrderCancelExpired]: 'This order has expired', + [ExchangeContractErrs.OrderCancelAmountZero]: 'Order cancel amount can\'t be 0', + [ExchangeContractErrs.OrderAlreadyCancelledOrFilled]: + 'This order has already been completely filled or cancelled', + [ExchangeContractErrs.OrderFillAmountZero]: 'Order fill amount can\'t be 0', + [ExchangeContractErrs.OrderRemainingFillAmountZero]: + 'This order has already been completely filled or cancelled', + [ExchangeContractErrs.OrderFillRoundingError]: 'Rounding error will occur when filling this order', + [ExchangeContractErrs.InsufficientTakerBalance]: + 'Taker no longer has a sufficient balance to complete this order', + [ExchangeContractErrs.InsufficientTakerAllowance]: + 'Taker no longer has a sufficient allowance to complete this order', + [ExchangeContractErrs.InsufficientMakerBalance]: + 'Maker no longer has a sufficient balance to complete this order', + [ExchangeContractErrs.InsufficientMakerAllowance]: + 'Maker no longer has a sufficient allowance to complete this order', + [ExchangeContractErrs.InsufficientTakerFeeBalance]: 'Taker no longer has a sufficient balance to pay fees', + [ExchangeContractErrs.InsufficientTakerFeeAllowance]: + 'Taker no longer has a sufficient allowance to pay fees', + [ExchangeContractErrs.InsufficientMakerFeeBalance]: 'Maker no longer has a sufficient balance to pay fees', + [ExchangeContractErrs.InsufficientMakerFeeAllowance]: + 'Maker no longer has a sufficient allowance to pay fees', + [ExchangeContractErrs.TransactionSenderIsNotFillOrderTaker]: + `This order can only be filled by ${takerAddress}`, + [ExchangeContractErrs.InsufficientRemainingFillAmount]: + 'Insufficient remaining fill amount', + }; + const humanReadableErrorMsg = exchangeContractErrorToHumanReadableError[error] || + ZeroExErrorToHumanReadableError[error]; + return humanReadableErrorMsg; + } + public async validateFillOrderThrowIfInvalidAsync(signedOrder: SignedOrder, + fillTakerTokenAmount: BigNumber, + takerAddress: string): Promise<void> { + await this.zeroEx.exchange.validateFillOrderThrowIfInvalidAsync( + signedOrder, fillTakerTokenAmount, takerAddress); + } + public async validateCancelOrderThrowIfInvalidAsync(order: Order, + cancelTakerTokenAmount: BigNumber): Promise<void> { + await this.zeroEx.exchange.validateCancelOrderThrowIfInvalidAsync(order, cancelTakerTokenAmount); + } + public isValidAddress(address: string): boolean { + const lowercaseAddress = address.toLowerCase(); + return this.web3Wrapper.isAddress(lowercaseAddress); + } + public async pollTokenBalanceAsync(token: Token) { + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + const [currBalance] = await this.getTokenBalanceAndAllowanceAsync(this.userAddress, token.address); + + this.zrxPollIntervalId = window.setInterval(async () => { + const [balance] = await this.getTokenBalanceAndAllowanceAsync(this.userAddress, token.address); + if (!balance.eq(currBalance)) { + this.dispatcher.replaceTokenBalanceByAddress(token.address, balance); + clearInterval(this.zrxPollIntervalId); + delete this.zrxPollIntervalId; + } + }, 5000); + } + public async signOrderHashAsync(orderHash: string): Promise<SignatureData> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + const makerAddress = this.userAddress; + // If makerAddress is undefined, this means they have a web3 instance injected into their browser + // but no account addresses associated with it. + if (_.isUndefined(makerAddress)) { + throw new Error('Tried to send a sign request but user has no associated addresses'); + } + const ecSignature = await this.zeroEx.signOrderHashAsync(orderHash, makerAddress); + const signatureData = _.extend({}, ecSignature, { + hash: orderHash, + }); + this.dispatcher.updateSignatureData(signatureData); + return signatureData; + } + public async mintTestTokensAsync(token: Token) { + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + const mintableContract = await this.instantiateContractIfExistsAsync(MintableArtifacts, token.address); + await mintableContract.mint(constants.MINT_AMOUNT, { + from: this.userAddress, + }); + const balanceDelta = constants.MINT_AMOUNT; + this.dispatcher.updateTokenBalanceByAddress(token.address, balanceDelta); + } + public async getBalanceInEthAsync(owner: string): Promise<BigNumber> { + const balance = await this.web3Wrapper.getBalanceInEthAsync(owner); + return balance; + } + public async convertEthToWrappedEthTokensAsync(amount: BigNumber): Promise<void> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + const txHash = await this.zeroEx.etherToken.depositAsync(amount, this.userAddress); + await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + } + public async convertWrappedEthTokensToEthAsync(amount: BigNumber): Promise<void> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + const txHash = await this.zeroEx.etherToken.withdrawAsync(amount, this.userAddress); + await this.showEtherScanLinkAndAwaitTransactionMinedAsync(txHash); + } + public async doesContractExistAtAddressAsync(address: string) { + const doesContractExist = await this.web3Wrapper.doesContractExistAtAddressAsync(address); + return doesContractExist; + } + public async getCurrentUserTokenBalanceAndAllowanceAsync(tokenAddress: string): Promise<BigNumber[]> { + const tokenBalanceAndAllowance = await this.getTokenBalanceAndAllowanceAsync(this.userAddress, tokenAddress); + return tokenBalanceAndAllowance; + } + public async getTokenBalanceAndAllowanceAsync(ownerAddress: string, tokenAddress: string): + Promise<BigNumber[]> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + + if (_.isEmpty(ownerAddress)) { + const zero = new BigNumber(0); + return [zero, zero]; + } + let balance = new BigNumber(0); + let allowance = new BigNumber(0); + if (this.doesUserAddressExist()) { + balance = await this.zeroEx.token.getBalanceAsync(tokenAddress, ownerAddress); + allowance = await this.zeroEx.token.getProxyAllowanceAsync(tokenAddress, ownerAddress); + } + return [balance, allowance]; + } + public async updateTokenBalancesAndAllowancesAsync(tokens: Token[]) { + const tokenStateByAddress: TokenStateByAddress = {}; + for (const token of tokens) { + let balance = new BigNumber(0); + let allowance = new BigNumber(0); + if (this.doesUserAddressExist()) { + [ + balance, + allowance, + ] = await this.getTokenBalanceAndAllowanceAsync(this.userAddress, token.address); + } + const tokenState = { + balance, + allowance, + }; + tokenStateByAddress[token.address] = tokenState; + } + this.dispatcher.updateTokenStateByAddress(tokenStateByAddress); + } + public async getUserAccountsAsync() { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + const userAccountsIfExists = await this.zeroEx.getAvailableAddressesAsync(); + return userAccountsIfExists; + } + // HACK: When a user is using a Ledger, we simply dispatch the selected userAddress, which + // by-passes the web3Wrapper logic for updating the prevUserAddress. We therefore need to + // manually update it. This should only be called by the LedgerConfigDialog. + public updateWeb3WrapperPrevUserAddress(newUserAddress: string) { + this.web3Wrapper.updatePrevUserAddress(newUserAddress); + } + public destroy() { + clearInterval(this.zrxPollIntervalId); + this.web3Wrapper.destroy(); + this.stopWatchingExchangeLogFillEventsAsync(); // fire and forget + } + private async showEtherScanLinkAndAwaitTransactionMinedAsync( + txHash: string): Promise<TransactionReceiptWithDecodedLogs> { + const etherScanLinkIfExists = utils.getEtherScanLinkIfExists(txHash, this.networkId, EtherscanLinkSuffixes.tx); + this.dispatcher.showFlashMessage(React.createElement(TransactionSubmitted, { + etherScanLinkIfExists, + })); + const receipt = await this.zeroEx.awaitTransactionMinedAsync(txHash); + return receipt; + } + private doesUserAddressExist(): boolean { + return this.userAddress !== ''; + } + private async rehydrateStoreWithContractEvents() { + // Ensure we are only ever listening to one set of events + await this.stopWatchingExchangeLogFillEventsAsync(); + + if (!this.doesUserAddressExist()) { + return; // short-circuit + } + + if (!_.isUndefined(this.zeroEx)) { + // Since we do not have an index on the `taker` address and want to show + // transactions where an account is either the `maker` or `taker`, we loop + // through all fill events, and filter/cache them client-side. + const filterIndexObj = {}; + await this.startListeningForExchangeLogFillEventsAsync(filterIndexObj); + } + } + private async startListeningForExchangeLogFillEventsAsync(indexFilterValues: IndexedFilterValues): Promise<void> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + utils.assert(this.doesUserAddressExist(), BlockchainCallErrs.USER_HAS_NO_ASSOCIATED_ADDRESSES); + + // Fetch historical logs + await this.fetchHistoricalExchangeLogFillEventsAsync(indexFilterValues); + + // Start a subscription for new logs + const exchangeAddress = this.getExchangeContractAddressIfExists(); + const subscriptionId = await this.zeroEx.exchange.subscribeAsync( + ExchangeEvents.LogFill, indexFilterValues, + async (err: Error, decodedLogEvent: DecodedLogEvent<LogFillContractEventArgs>) => { + const decodedLog = decodedLogEvent.log; + if (err) { + // Note: it's not entirely clear from the documentation which + // errors will be thrown by `watch`. For now, let's log the error + // to rollbar and stop watching when one occurs + errorReporter.reportAsync(err); // fire and forget + this.stopWatchingExchangeLogFillEventsAsync(); // fire and forget + return; + } else { + if (!this.doesLogEventInvolveUser(decodedLog)) { + return; // We aren't interested in the fill event + } + this.updateLatestFillsBlockIfNeeded(decodedLog.blockNumber); + const fill = await this.convertDecodedLogToFillAsync(decodedLog); + if (decodedLogEvent.isRemoved) { + tradeHistoryStorage.removeFillFromUser(this.userAddress, this.networkId, fill); + } else { + tradeHistoryStorage.addFillToUser(this.userAddress, this.networkId, fill); + } + } + }); + } + private async fetchHistoricalExchangeLogFillEventsAsync(indexFilterValues: IndexedFilterValues) { + const fromBlock = tradeHistoryStorage.getFillsLatestBlock(this.userAddress, this.networkId); + const subscriptionOpts: SubscriptionOpts = { + fromBlock, + toBlock: 'latest' as BlockParam, + }; + const decodedLogs = await this.zeroEx.exchange.getLogsAsync<LogFillContractEventArgs>( + ExchangeEvents.LogFill, subscriptionOpts, indexFilterValues, + ); + for (const decodedLog of decodedLogs) { + if (!this.doesLogEventInvolveUser(decodedLog)) { + continue; // We aren't interested in the fill event + } + this.updateLatestFillsBlockIfNeeded(decodedLog.blockNumber); + const fill = await this.convertDecodedLogToFillAsync(decodedLog); + tradeHistoryStorage.addFillToUser(this.userAddress, this.networkId, fill); + } + } + private async convertDecodedLogToFillAsync(decodedLog: LogWithDecodedArgs<LogFillContractEventArgs>) { + const args = decodedLog.args as LogFillContractEventArgs; + const blockTimestamp = await this.web3Wrapper.getBlockTimestampAsync(decodedLog.blockHash); + const fill = { + filledTakerTokenAmount: args.filledTakerTokenAmount, + filledMakerTokenAmount: args.filledMakerTokenAmount, + logIndex: decodedLog.logIndex, + maker: args.maker, + orderHash: args.orderHash, + taker: args.taker, + makerToken: args.makerToken, + takerToken: args.takerToken, + paidMakerFee: args.paidMakerFee, + paidTakerFee: args.paidTakerFee, + transactionHash: decodedLog.transactionHash, + blockTimestamp, + }; + return fill; + } + private doesLogEventInvolveUser(decodedLog: LogWithDecodedArgs<LogFillContractEventArgs>) { + const args = decodedLog.args as LogFillContractEventArgs; + const isUserMakerOrTaker = args.maker === this.userAddress || + args.taker === this.userAddress; + return isUserMakerOrTaker; + } + private updateLatestFillsBlockIfNeeded(blockNumber: number) { + const isBlockPending = _.isNull(blockNumber); + if (!isBlockPending) { + // Hack: I've observed the behavior where a client won't register certain fill events + // and lowering the cache blockNumber fixes the issue. As a quick fix for now, simply + // set the cached blockNumber 50 below the one returned. This way, upon refreshing, a user + // would still attempt to re-fetch events from the previous 50 blocks, but won't need to + // re-fetch all events in all blocks. + // TODO: Debug if this is a race condition, and apply a more precise fix + const blockNumberToSet = blockNumber - BLOCK_NUMBER_BACK_TRACK < 0 ? + 0 : + blockNumber - BLOCK_NUMBER_BACK_TRACK; + tradeHistoryStorage.setFillsLatestBlock(this.userAddress, this.networkId, blockNumberToSet); + } + } + private async stopWatchingExchangeLogFillEventsAsync() { + this.zeroEx.exchange.unsubscribeAll(); + } + private async getTokenRegistryTokensByAddressAsync(): Promise<TokenByAddress> { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + const tokenRegistryTokens = await this.zeroEx.tokenRegistry.getTokensAsync(); + + const tokenByAddress: TokenByAddress = {}; + _.each(tokenRegistryTokens, (t: ZeroExToken, i: number) => { + // HACK: For now we have a hard-coded list of iconUrls for the dummyTokens + // TODO: Refactor this out and pull the iconUrl directly from the TokenRegistry + const iconUrl = constants.iconUrlBySymbol[t.symbol]; + const token: Token = { + iconUrl, + address: t.address, + name: t.name, + symbol: t.symbol, + decimals: t.decimals, + isTracked: false, + isRegistered: true, + }; + tokenByAddress[token.address] = token; + }); + return tokenByAddress; + } + private async onPageLoadInitFireAndForgetAsync() { + await this.onPageLoadAsync(); // wait for page to load + + // Hack: We need to know the networkId the injectedWeb3 is connected to (if it is defined) in + // order to properly instantiate the web3Wrapper. Since we must use the async call, we cannot + // retrieve it from within the web3Wrapper constructor. This is and should remain the only + // call to a web3 instance outside of web3Wrapper in the entire dapp. + // In addition, if the user has an injectedWeb3 instance that is disconnected from a backing + // Ethereum node, this call will throw. We need to handle this case gracefully + const injectedWeb3 = (window as any).web3; + let networkId: number; + if (!_.isUndefined(injectedWeb3)) { + try { + networkId = _.parseInt(await promisify(injectedWeb3.version.getNetwork)()); + } catch (err) { + // Ignore error and proceed with networkId undefined + } + } + + const provider = await this.getProviderAsync(injectedWeb3, networkId); + this.zeroEx = new ZeroEx(provider); + await this.updateProviderName(injectedWeb3); + const shouldPollUserAddress = true; + this.web3Wrapper = new Web3Wrapper(this.dispatcher, provider, networkId, shouldPollUserAddress); + await this.postInstantiationOrUpdatingProviderZeroExAsync(); + } + // This method should always be run after instantiating or updating the provider + // of the ZeroEx instance. + private async postInstantiationOrUpdatingProviderZeroExAsync() { + utils.assert(!_.isUndefined(this.zeroEx), 'ZeroEx must be instantiated.'); + this.exchangeAddress = await this.zeroEx.exchange.getContractAddressAsync(); + } + private updateProviderName(injectedWeb3: Web3) { + const doesInjectedWeb3Exist = !_.isUndefined(injectedWeb3); + const providerName = doesInjectedWeb3Exist ? + this.getNameGivenProvider(injectedWeb3.currentProvider) : + constants.PUBLIC_PROVIDER_NAME; + this.dispatcher.updateInjectedProviderName(providerName); + } + // This is only ever called by the LedgerWallet subprovider in order to retrieve + // the current networkId without this value going stale. + private getBlockchainNetworkId() { + return this.networkId; + } + private async getProviderAsync(injectedWeb3: Web3, networkIdIfExists: number) { + const doesInjectedWeb3Exist = !_.isUndefined(injectedWeb3); + const publicNodeUrlsIfExistsForNetworkId = constants.PUBLIC_NODE_URLS_BY_NETWORK_ID[networkIdIfExists]; + const isPublicNodeAvailableForNetworkId = !_.isUndefined(publicNodeUrlsIfExistsForNetworkId); + + let provider; + if (doesInjectedWeb3Exist && isPublicNodeAvailableForNetworkId) { + // We catch all requests involving a users account and send it to the injectedWeb3 + // instance. All other requests go to the public hosted node. + provider = new ProviderEngine(); + provider.addProvider(new InjectedWeb3SubProvider(injectedWeb3)); + provider.addProvider(new FilterSubprovider()); + provider.addProvider(new RedundantRPCSubprovider( + publicNodeUrlsIfExistsForNetworkId, + )); + provider.start(); + } else if (doesInjectedWeb3Exist) { + // Since no public node for this network, all requests go to injectedWeb3 instance + provider = injectedWeb3.currentProvider; + } else { + // If no injectedWeb3 instance, all requests fallback to our public hosted mainnet/testnet node + // We do this so that users can still browse the 0x Portal DApp even if they do not have web3 + // injected into their browser. + provider = new ProviderEngine(); + provider.addProvider(new FilterSubprovider()); + const networkId = configs.isMainnetEnabled ? + constants.MAINNET_NETWORK_ID : + constants.TESTNET_NETWORK_ID; + provider.addProvider(new RedundantRPCSubprovider( + constants.PUBLIC_NODE_URLS_BY_NETWORK_ID[networkId], + )); + provider.start(); + } + + return provider; + } + private getNameGivenProvider(provider: Web3.Provider): string { + if (!_.isUndefined((provider as any).isMetaMask)) { + return constants.METAMASK_PROVIDER_NAME; + } + + // HACK: We use the fact that Parity Signer's provider is an instance of their + // internal `Web3FrameProvider` class. + const isParitySigner = _.startsWith(provider.constructor.toString(), 'function Web3FrameProvider'); + if (isParitySigner) { + return constants.PARITY_SIGNER_PROVIDER_NAME; + } + + return constants.GENERIC_PROVIDER_NAME; + } + private async fetchTokenInformationAsync() { + utils.assert(!_.isUndefined(this.networkId), + 'Cannot call fetchTokenInformationAsync if disconnected from Ethereum node'); + + this.dispatcher.updateBlockchainIsLoaded(false); + this.dispatcher.clearTokenByAddress(); + + const tokenRegistryTokensByAddress = await this.getTokenRegistryTokensByAddressAsync(); + + // HACK: We need to fetch the userAddress here because otherwise we cannot save the + // tracked tokens in localStorage under the users address nor fetch the token + // balances and allowances and we need to do this in order not to trigger the blockchain + // loading dialog to show up twice. First to load the contracts, and second to load the + // balances and allowances. + this.userAddress = await this.web3Wrapper.getFirstAccountIfExistsAsync(); + if (!_.isEmpty(this.userAddress)) { + this.dispatcher.updateUserAddress(this.userAddress); + } + + let trackedTokensIfExists = trackedTokenStorage.getTrackedTokensIfExists(this.userAddress, this.networkId); + const tokenRegistryTokens = _.values(tokenRegistryTokensByAddress); + if (_.isUndefined(trackedTokensIfExists)) { + trackedTokensIfExists = _.map(configs.defaultTrackedTokenSymbols, symbol => { + const token = _.find(tokenRegistryTokens, t => t.symbol === symbol); + token.isTracked = true; + return token; + }); + _.each(trackedTokensIfExists, token => { + trackedTokenStorage.addTrackedTokenToUser(this.userAddress, this.networkId, token); + }); + } else { + // Properly set all tokenRegistry tokens `isTracked` to true if they are in the existing trackedTokens array + _.each(trackedTokensIfExists, trackedToken => { + if (!_.isUndefined(tokenRegistryTokensByAddress[trackedToken.address])) { + tokenRegistryTokensByAddress[trackedToken.address].isTracked = true; + } + }); + } + const allTokens = _.uniq([...tokenRegistryTokens, ...trackedTokensIfExists]); + this.dispatcher.updateTokenByAddress(allTokens); + + // Get balance/allowance for tracked tokens + await this.updateTokenBalancesAndAllowancesAsync(trackedTokensIfExists); + + const mostPopularTradingPairTokens: Token[] = [ + _.find(allTokens, {symbol: configs.defaultTrackedTokenSymbols[0]}), + _.find(allTokens, {symbol: configs.defaultTrackedTokenSymbols[1]}), + ]; + this.dispatcher.updateChosenAssetTokenAddress(Side.deposit, mostPopularTradingPairTokens[0].address); + this.dispatcher.updateChosenAssetTokenAddress(Side.receive, mostPopularTradingPairTokens[1].address); + this.dispatcher.updateBlockchainIsLoaded(true); + } + private async instantiateContractIfExistsAsync(artifact: any, address?: string): Promise<ContractInstance> { + const c = await contract(artifact); + const providerObj = this.web3Wrapper.getProviderObj(); + c.setProvider(providerObj); + + const artifactNetworkConfigs = artifact.networks[this.networkId]; + let contractAddress; + if (!_.isUndefined(address)) { + contractAddress = address; + } else if (!_.isUndefined(artifactNetworkConfigs)) { + contractAddress = artifactNetworkConfigs.address; + } + + if (!_.isUndefined(contractAddress)) { + const doesContractExist = await this.doesContractExistAtAddressAsync(contractAddress); + if (!doesContractExist) { + utils.consoleLog(`Contract does not exist: ${artifact.contract_name} at ${contractAddress}`); + throw new Error(BlockchainCallErrs.CONTRACT_DOES_NOT_EXIST); + } + } + + try { + const contractInstance = _.isUndefined(address) ? + await c.deployed() : + await c.at(address); + return contractInstance; + } catch (err) { + const errMsg = `${err}`; + utils.consoleLog(`Notice: Error encountered: ${err} ${err.stack}`); + if (_.includes(errMsg, 'not been deployed to detected network')) { + throw new Error(BlockchainCallErrs.CONTRACT_DOES_NOT_EXIST); + } else { + await errorReporter.reportAsync(err); + throw new Error(BlockchainCallErrs.UNHANDLED_ERROR); + } + } + } + private async onPageLoadAsync() { + if (document.readyState === 'complete') { + return; // Already loaded + } + return new Promise((resolve, reject) => { + window.onload = resolve; + }); + } +} 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..410b0440a --- /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); + + const [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(token.address); + this.props.dispatcher.updateTokenStateByAddress({ + [token.address]: { + balance, + allowance, + }, + }); + this.props.dispatcher.updateTokenByAddress([newTokenEntry]); + 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..2d533a9f2 --- /dev/null +++ b/packages/website/ts/components/token_balances.tsx @@ -0,0 +1,699 @@ +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 + key="errorOkBtn" + label="Ok" + primary={true} + onTouchTap={this.onErrorDialogToggle.bind(this, false)} + />, + ]; + const dharmaDialogActions = [ + <FlatButton + key="dharmaCloseBtn" + 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..58bdf84ae --- /dev/null +++ b/packages/website/ts/components/trade_history/trade_history_item.tsx @@ -0,0 +1,181 @@ +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; + } else { + // This condition should never be hit + throw new Error('Found Fill that wasn\'t performed by this user'); + } + + 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> + ); + } +} diff --git a/packages/website/ts/containers/generate_order_form.tsx b/packages/website/ts/containers/generate_order_form.tsx new file mode 100644 index 000000000..97b5172e7 --- /dev/null +++ b/packages/website/ts/containers/generate_order_form.tsx @@ -0,0 +1,54 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {connect} from 'react-redux'; +import {Store as ReduxStore, Dispatch} from 'redux'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {State} from 'ts/redux/reducer'; +import {Blockchain} from 'ts/blockchain'; +import {GenerateOrderForm as GenerateOrderFormComponent} from 'ts/components/generate_order/generate_order_form'; +import { + SideToAssetToken, + SignatureData, + HashData, + TokenByAddress, + TokenStateByAddress, + BlockchainErrs, +} from 'ts/types'; +import BigNumber from 'bignumber.js'; + +interface GenerateOrderFormProps { + blockchain: Blockchain; + hashData: HashData; + dispatcher: Dispatcher; +} + +interface ConnectedState { + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + orderExpiryTimestamp: BigNumber; + orderSignatureData: SignatureData; + userAddress: string; + orderTakerAddress: string; + orderSalt: BigNumber; + networkId: number; + sideToAssetToken: SideToAssetToken; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; +} + +const mapStateToProps = (state: State, ownProps: GenerateOrderFormProps): ConnectedState => ({ + blockchainErr: state.blockchainErr, + blockchainIsLoaded: state.blockchainIsLoaded, + orderExpiryTimestamp: state.orderExpiryTimestamp, + orderSignatureData: state.orderSignatureData, + orderTakerAddress: state.orderTakerAddress, + orderSalt: state.orderSalt, + networkId: state.networkId, + sideToAssetToken: state.sideToAssetToken, + tokenByAddress: state.tokenByAddress, + tokenStateByAddress: state.tokenStateByAddress, + userAddress: state.userAddress, +}); + +export const GenerateOrderForm: React.ComponentClass<GenerateOrderFormProps> = + connect(mapStateToProps)(GenerateOrderFormComponent); diff --git a/packages/website/ts/containers/portal.tsx b/packages/website/ts/containers/portal.tsx new file mode 100644 index 000000000..805058aa3 --- /dev/null +++ b/packages/website/ts/containers/portal.tsx @@ -0,0 +1,94 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {connect} from 'react-redux'; +import {Store as ReduxStore, Dispatch} from 'redux'; +import {State} from 'ts/redux/reducer'; +import {constants} from 'ts/utils/constants'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + Side, + HashData, + TokenByAddress, + BlockchainErrs, + Fill, + Order, + ScreenWidths, + TokenStateByAddress, +} from 'ts/types'; +import { + Portal as PortalComponent, + PortalAllProps as PortalComponentAllProps, + PortalPassedProps as PortalComponentPassedProps, +} from 'ts/components/portal'; +import BigNumber from 'bignumber.js'; + +interface MapStateToProps { + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + hashData: HashData; + networkId: number; + nodeVersion: string; + orderFillAmount: number; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; + userEtherBalance: number; + screenWidth: ScreenWidths; + shouldBlockchainErrDialogBeOpen: boolean; + userAddress: string; + userSuppliedOrderCache: Order; +} + +interface ConnectedState {} + +interface ConnectedDispatch { + dispatcher: Dispatcher; +} + +const mapStateToProps = (state: State, ownProps: PortalComponentAllProps): ConnectedState => { + const receiveAssetToken = state.sideToAssetToken[Side.receive]; + const depositAssetToken = state.sideToAssetToken[Side.deposit]; + const receiveAddress = !_.isUndefined(receiveAssetToken.address) ? + receiveAssetToken.address : constants.NULL_ADDRESS; + const depositAddress = !_.isUndefined(depositAssetToken.address) ? + depositAssetToken.address : constants.NULL_ADDRESS; + const receiveAmount = !_.isUndefined(receiveAssetToken.amount) ? + receiveAssetToken.amount : new BigNumber(0); + const depositAmount = !_.isUndefined(depositAssetToken.amount) ? + depositAssetToken.amount : new BigNumber(0); + const hashData = { + depositAmount, + depositTokenContractAddr: depositAddress, + feeRecipientAddress: constants.FEE_RECIPIENT_ADDRESS, + makerFee: constants.MAKER_FEE, + orderExpiryTimestamp: state.orderExpiryTimestamp, + orderMakerAddress: state.userAddress, + orderTakerAddress: state.orderTakerAddress !== '' ? state.orderTakerAddress : constants.NULL_ADDRESS, + receiveAmount, + receiveTokenContractAddr: receiveAddress, + takerFee: constants.TAKER_FEE, + orderSalt: state.orderSalt, + }; + return { + blockchainErr: state.blockchainErr, + blockchainIsLoaded: state.blockchainIsLoaded, + networkId: state.networkId, + nodeVersion: state.nodeVersion, + orderFillAmount: state.orderFillAmount, + hashData, + screenWidth: state.screenWidth, + shouldBlockchainErrDialogBeOpen: state.shouldBlockchainErrDialogBeOpen, + tokenByAddress: state.tokenByAddress, + tokenStateByAddress: state.tokenStateByAddress, + userAddress: state.userAddress, + userEtherBalance: state.userEtherBalance, + userSuppliedOrderCache: state.userSuppliedOrderCache, + flashMessage: state.flashMessage, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch<State>): ConnectedDispatch => ({ + dispatcher: new Dispatcher(dispatch), +}); + +export const Portal: React.ComponentClass<PortalComponentPassedProps> = + connect(mapStateToProps, mapDispatchToProps)(PortalComponent); diff --git a/packages/website/ts/containers/smart_contracts_documentation.tsx b/packages/website/ts/containers/smart_contracts_documentation.tsx new file mode 100644 index 000000000..5d05bdd2f --- /dev/null +++ b/packages/website/ts/containers/smart_contracts_documentation.tsx @@ -0,0 +1,31 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {connect} from 'react-redux'; +import {Store as ReduxStore, Dispatch} from 'redux'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {State} from 'ts/redux/reducer'; +import { + SmartContractsDocumentation as SmartContractsDocumentationComponent, + SmartContractsDocumentationAllProps, +} from 'ts/pages/documentation/smart_contracts_documentation'; + +interface ConnectedState { + docsVersion: string; + availableDocVersions: string[]; +} + +interface ConnectedDispatch { + dispatcher: Dispatcher; +} + +const mapStateToProps = (state: State, ownProps: SmartContractsDocumentationAllProps): ConnectedState => ({ + docsVersion: state.docsVersion, + availableDocVersions: state.availableDocVersions, +}); + +const mapDispatchToProps = (dispatch: Dispatch<State>): ConnectedDispatch => ({ + dispatcher: new Dispatcher(dispatch), +}); + +export const SmartContractsDocumentation: React.ComponentClass<SmartContractsDocumentationAllProps> = + connect(mapStateToProps, mapDispatchToProps)(SmartContractsDocumentationComponent); diff --git a/packages/website/ts/containers/zero_ex_js_documentation.tsx b/packages/website/ts/containers/zero_ex_js_documentation.tsx new file mode 100644 index 000000000..a5b8298b5 --- /dev/null +++ b/packages/website/ts/containers/zero_ex_js_documentation.tsx @@ -0,0 +1,33 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {connect} from 'react-redux'; +import {Store as ReduxStore, Dispatch} from 'redux'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {State} from 'ts/redux/reducer'; +import {Blockchain} from 'ts/blockchain'; +import { + ZeroExJSDocumentation as ZeroExJSDocumentationComponent, + ZeroExJSDocumentationAllProps, +} from 'ts/pages/documentation/zero_ex_js_documentation'; +import BigNumber from 'bignumber.js'; + +interface ConnectedState { + docsVersion: string; + availableDocVersions: string[]; +} + +interface ConnectedDispatch { + dispatcher: Dispatcher; +} + +const mapStateToProps = (state: State, ownProps: ZeroExJSDocumentationAllProps): ConnectedState => ({ + docsVersion: state.docsVersion, + availableDocVersions: state.availableDocVersions, +}); + +const mapDispatchToProps = (dispatch: Dispatch<State>): ConnectedDispatch => ({ + dispatcher: new Dispatcher(dispatch), +}); + +export const ZeroExJSDocumentation: React.ComponentClass<ZeroExJSDocumentationAllProps> = + connect(mapStateToProps, mapDispatchToProps)(ZeroExJSDocumentationComponent); diff --git a/packages/website/ts/globals.d.ts b/packages/website/ts/globals.d.ts new file mode 100644 index 000000000..ee449ecfd --- /dev/null +++ b/packages/website/ts/globals.d.ts @@ -0,0 +1,154 @@ +declare module 'react-tooltip'; +declare module 'react-router-hash-link'; +declare module 'es6-promisify'; +declare module 'truffle-contract'; +declare module 'ethereumjs-util'; +declare module 'keccak'; +declare module 'web3-provider-engine'; +declare module 'whatwg-fetch'; +declare module 'react-html5video'; +declare module 'web3-provider-engine/subproviders/filters'; +declare module 'thenby'; +declare module 'react-highlight'; +declare module 'react-recaptcha'; +declare module 'react-document-title'; +declare module 'ledgerco'; +declare module 'ethereumjs-tx'; + +declare module '*.json' { + const json: any; + /* tslint:disable */ + export default json; + /* tslint:enable */ +} + +// find-version declarations +declare function findVersions(version: string): string[]; +declare module 'find-versions' { + export = findVersions; +} + +// compare-version declarations +declare function compareVersions(firstVersion: string, secondVersion: string): number; +declare module 'compare-versions' { + export = compareVersions; +} + +// semver-sort declarations +declare module 'semver-sort' { + const desc: (versions: string[]) => string[]; +} + +// xml-js declarations +declare interface XML2JSONOpts { + compact?: boolean; + spaces?: number; +} +declare module 'xml-js' { + const xml2json: (xml: string, opts: XML2JSONOpts) => string; +} + +// This will be defined by default in TS 2.4 +// Source: https://github.com/Microsoft/TypeScript/issues/12364 +interface System { + import<T>(module: string): Promise<T>; +} +declare var System: System; + +// ethereum-address declarations +declare module 'ethereum-address' { + export const isAddress: (address: string) => boolean; +} + +// jsonschema declarations +// Source: https://github.com/tdegrunt/jsonschema/blob/master/lib/index.d.ts +declare interface Schema { + id?: string; + $schema?: string; + title?: string; + description?: string; + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: boolean; + minimum?: number; + exclusiveMinimum?: boolean; + maxLength?: number; + minLength?: number; + pattern?: string; + additionalItems?: boolean | Schema; + items?: Schema | Schema[]; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + maxProperties?: number; + minProperties?: number; + required?: string[]; + additionalProperties?: boolean | Schema; + definitions?: { + [name: string]: Schema; + }; + properties?: { + [name: string]: Schema; + }; + patternProperties?: { + [name: string]: Schema; + }; + dependencies?: { + [name: string]: Schema | string[]; + }; + 'enum'?: any[]; + type?: string | string[]; + allOf?: Schema[]; + anyOf?: Schema[]; + oneOf?: Schema[]; + not?: Schema; + // This is the only property that's not defined in https://github.com/tdegrunt/jsonschema/blob/master/lib/index.d.ts + // There is an open issue for that: https://github.com/tdegrunt/jsonschema/issues/194 + // There is also an opened PR: https://github.com/tdegrunt/jsonschema/pull/218/files + // As soon as it gets merged we should be good to use types from 'jsonschema' package + $ref?: string; +} + +// blockies declarations +declare interface BlockiesIcon { + toDataURL(): string; +} +declare interface BlockiesConfig { + seed: string; +} +declare function blockies(config: BlockiesConfig): BlockiesIcon; +declare module 'blockies' { + export = blockies; +} + +// is-mobile declarations +declare function isMobile(): boolean; +declare module 'is-mobile' { + export = isMobile; +} + +// web3-provider-engine declarations +declare class Subprovider {} +declare module 'web3-provider-engine/subproviders/subprovider' { + export = Subprovider; +} +declare class RpcSubprovider { + constructor(options: {rpcUrl: string}); + public handleRequest(payload: any, next: any, end: (err?: Error, data?: any) => void): void; +} +declare module 'web3-provider-engine/subproviders/rpc' { + export = RpcSubprovider; +} +declare class HookedWalletSubprovider { + constructor(wallet: any); +} +declare module 'web3-provider-engine/subproviders/hooked-wallet' { + export = HookedWalletSubprovider; +} + +declare interface Artifact { + abi: any; + networks: {[networkId: number]: { + address: string; + }}; +} diff --git a/packages/website/ts/index.tsx b/packages/website/ts/index.tsx new file mode 100644 index 000000000..ed4d09956 --- /dev/null +++ b/packages/website/ts/index.tsx @@ -0,0 +1,116 @@ +// Polyfills +import 'whatwg-fetch'; + +import * as React from 'react'; +import {render} from 'react-dom'; +import {Provider} from 'react-redux'; +import {createStore, Store as ReduxStore} from 'redux'; +import BigNumber from 'bignumber.js'; +import {constants} from 'ts/utils/constants'; +import {Landing} from 'ts/pages/landing/landing'; +import {FAQ} from 'ts/pages/faq/faq'; +import {About} from 'ts/pages/about/about'; +import {Wiki} from 'ts/pages/wiki/wiki'; +import {NotFound} from 'ts/pages/not_found'; +import {createLazyComponent} from 'ts/lazy_component'; +import {State, reducer} from 'ts/redux/reducer'; +import {colors, getMuiTheme, MuiThemeProvider} from 'material-ui/styles'; +import {Switch, BrowserRouter as Router, Route, Link, Redirect} from 'react-router-dom'; +import {tradeHistoryStorage} from 'ts/local_storage/trade_history_storage'; +import * as injectTapEventPlugin from 'react-tap-event-plugin'; +import {WebsitePaths} from 'ts/types'; +injectTapEventPlugin(); + +// By default BigNumber's `toString` method converts to exponential notation if the value has +// more then 20 digits. We want to avoid this behavior, so we set EXPONENTIAL_AT to a high number +BigNumber.config({ + EXPONENTIAL_AT: 1000, +}); + +// Check if we've introduced an update that requires us to clear the tradeHistory local storage entries +tradeHistoryStorage.clearIfRequired(); + +const CUSTOM_GREY = 'rgb(39, 39, 39)'; +const CUSTOM_GREEN = 'rgb(102, 222, 117)'; +const CUSTOM_DARKER_GREEN = 'rgb(77, 197, 92)'; + +import 'basscss/css/basscss.css'; +import 'less/all.less'; + +const muiTheme = getMuiTheme({ + appBar: { + height: 45, + color: 'white', + textColor: 'black', + }, + palette: { + pickerHeaderColor: constants.CUSTOM_BLUE, + primary1Color: constants.CUSTOM_BLUE, + primary2Color: constants.CUSTOM_BLUE, + textColor: colors.grey700, + }, + datePicker: { + color: colors.grey700, + textColor: 'white', + calendarTextColor: 'white', + selectColor: CUSTOM_GREY, + selectTextColor: 'white', + }, + timePicker: { + color: colors.grey700, + textColor: 'white', + accentColor: 'white', + headerColor: CUSTOM_GREY, + selectColor: CUSTOM_GREY, + selectTextColor: CUSTOM_GREY, + }, + toggle: { + thumbOnColor: CUSTOM_GREEN, + trackOnColor: CUSTOM_DARKER_GREEN, + }, +}); + +// We pass modulePromise returning lambda instead of module promise, +// cause we only want to import the module when the user navigates to the page. +// At the same time webpack statically parses for System.import() to determine bundle chunk split points +// so each lazy import needs it's own `System.import()` declaration. +const LazyPortal = createLazyComponent( + 'Portal', () => System.import<any>(/* webpackChunkName: "portal" */'ts/containers/portal'), +); +const LazyZeroExJSDocumentation = createLazyComponent( + 'ZeroExJSDocumentation', + () => System.import<any>(/* webpackChunkName: "zeroExDocs" */'ts/containers/zero_ex_js_documentation'), +); +const LazySmartContractsDocumentation = createLazyComponent( + 'SmartContractsDocumentation', + () => System.import<any>(/* webpackChunkName: "smartContractDocs" */'ts/containers/smart_contracts_documentation'), +); + +const store: ReduxStore<State> = createStore(reducer); +render( + <Router> + <div> + <MuiThemeProvider muiTheme={muiTheme}> + <Provider store={store}> + <div> + <Switch> + <Route exact={true} path="/" component={Landing as any} /> + <Redirect from="/otc" to={`${WebsitePaths.Portal}`}/> + <Route path={`${WebsitePaths.Portal}`} component={LazyPortal} /> + <Route path={`${WebsitePaths.FAQ}`} component={FAQ as any} /> + <Route path={`${WebsitePaths.About}`} component={About as any} /> + <Route path={`${WebsitePaths.Wiki}`} component={Wiki as any} /> + <Route path={`${WebsitePaths.ZeroExJs}/:version?`} component={LazyZeroExJSDocumentation} /> + <Route + path={`${WebsitePaths.SmartContracts}/:version?`} + component={LazySmartContractsDocumentation} + /> + <Route component={NotFound as any} /> + </Switch> + </div> + </Provider> + </MuiThemeProvider> + </div> + </Router>, + document.getElementById('app'), +); diff --git a/packages/website/ts/lazy_component.tsx b/packages/website/ts/lazy_component.tsx new file mode 100644 index 000000000..7052b7be6 --- /dev/null +++ b/packages/website/ts/lazy_component.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import * as _ from 'lodash'; + +interface LazyComponentProps { + reactComponentPromise: Promise<React.ComponentClass<any>>; + reactComponentProps: any; +} + +interface LazyComponentState { + component?: React.ComponentClass<any>; +} + +/** + * This component is used for rendering components that are lazily loaded from other chunks. + * Source: https://reacttraining.com/react-router/web/guides/code-splitting + */ +export class LazyComponent extends React.Component<LazyComponentProps, LazyComponentState> { + constructor(props: LazyComponentProps) { + super(props); + this.state = { + component: undefined, + }; + } + public componentWillMount() { + this.loadComponentFireAndForgetAsync(this.props); + } + public componentWillReceiveProps(nextProps: LazyComponentProps) { + if (nextProps.reactComponentPromise !== this.props.reactComponentPromise) { + this.loadComponentFireAndForgetAsync(nextProps); + } + } + public render() { + return _.isUndefined(this.state.component) ? + null : + React.createElement(this.state.component, this.props.reactComponentProps); + } + private async loadComponentFireAndForgetAsync(props: LazyComponentProps) { + const component = await props.reactComponentPromise; + this.setState({ + component, + }); + } +} + +/** + * [createLazyComponent description] + * @param componentName name of exported component + * @param lazyImport lambda returning module promise + * we pass a lambda because we only want to require a module if it's used + * @example `const LazyPortal = createLazyComponent('Portal', () => System.import<any>('ts/containers/portal'));`` + */ +export const createLazyComponent = (componentName: string, lazyImport: () => Promise<any>) => { + return (props: any) => { + const reactComponentPromise = (async (): Promise<React.ComponentClass<any>> => { + const mod = await lazyImport(); + const component = mod[componentName]; + return component; + })(); + return ( + <LazyComponent + reactComponentPromise={reactComponentPromise} + reactComponentProps={props} + /> + ); + }; +}; diff --git a/packages/website/ts/local_storage/local_storage.ts b/packages/website/ts/local_storage/local_storage.ts new file mode 100644 index 000000000..d94e6877b --- /dev/null +++ b/packages/website/ts/local_storage/local_storage.ts @@ -0,0 +1,35 @@ +import * as _ from 'lodash'; + +export const localStorage = { + doesExist() { + return !!window.localStorage; + }, + getItemIfExists(key: string): string { + if (!this.doesExist) { + return undefined; + } + const item = window.localStorage.getItem(key); + if (_.isNull(item) || item === 'undefined') { + return ''; + } + return item; + }, + setItem(key: string, value: string) { + if (!this.doesExist || _.isUndefined(value)) { + return; + } + window.localStorage.setItem(key, value); + }, + removeItem(key: string) { + if (!this.doesExist) { + return; + } + window.localStorage.removeItem(key); + }, + getAllKeys(): string[] { + if (!this.doesExist) { + return []; + } + return _.keys(window.localStorage); + }, +}; diff --git a/packages/website/ts/local_storage/tracked_token_storage.ts b/packages/website/ts/local_storage/tracked_token_storage.ts new file mode 100644 index 000000000..0b54a66e0 --- /dev/null +++ b/packages/website/ts/local_storage/tracked_token_storage.ts @@ -0,0 +1,56 @@ +import * as _ from 'lodash'; +import {Token, TrackedTokensByNetworkId} from 'ts/types'; +import {localStorage} from 'ts/local_storage/local_storage'; + +const TRACKED_TOKENS_KEY = 'trackedTokens'; + +export const trackedTokenStorage = { + addTrackedTokenToUser(userAddress: string, networkId: number, token: Token) { + const trackedTokensByUserAddress = this.getTrackedTokensByUserAddress(); + let trackedTokensByNetworkId = trackedTokensByUserAddress[userAddress]; + if (_.isUndefined(trackedTokensByNetworkId)) { + trackedTokensByNetworkId = {}; + } + const trackedTokens = !_.isUndefined(trackedTokensByNetworkId[networkId]) ? + trackedTokensByNetworkId[networkId] : + []; + trackedTokens.push(token); + trackedTokensByNetworkId[networkId] = trackedTokens; + trackedTokensByUserAddress[userAddress] = trackedTokensByNetworkId; + const trackedTokensByUserAddressJSONString = JSON.stringify(trackedTokensByUserAddress); + localStorage.setItem(TRACKED_TOKENS_KEY, trackedTokensByUserAddressJSONString); + }, + getTrackedTokensByUserAddress(): TrackedTokensByNetworkId { + const trackedTokensJSONString = localStorage.getItemIfExists(TRACKED_TOKENS_KEY); + if (_.isEmpty(trackedTokensJSONString)) { + return {}; + } + const trackedTokensByUserAddress = JSON.parse(trackedTokensJSONString); + return trackedTokensByUserAddress; + }, + getTrackedTokensIfExists(userAddress: string, networkId: number): Token[] { + const trackedTokensJSONString = localStorage.getItemIfExists(TRACKED_TOKENS_KEY); + if (_.isEmpty(trackedTokensJSONString)) { + return undefined; + } + const trackedTokensByUserAddress = JSON.parse(trackedTokensJSONString); + const trackedTokensByNetworkId = trackedTokensByUserAddress[userAddress]; + if (_.isUndefined(trackedTokensByNetworkId)) { + return undefined; + } + const trackedTokens = trackedTokensByNetworkId[networkId]; + return trackedTokens; + }, + removeTrackedToken(userAddress: string, networkId: number, tokenAddress: string) { + const trackedTokensByUserAddress = this.getTrackedTokensByUserAddress(); + const trackedTokensByNetworkId = trackedTokensByUserAddress[userAddress]; + const trackedTokens = trackedTokensByNetworkId[networkId]; + const remainingTrackedTokens = _.filter(trackedTokens, (token: Token) => { + return token.address !== tokenAddress; + }); + trackedTokensByNetworkId[networkId] = remainingTrackedTokens; + trackedTokensByUserAddress[userAddress] = trackedTokensByNetworkId; + const trackedTokensByUserAddressJSONString = JSON.stringify(trackedTokensByUserAddress); + localStorage.setItem(TRACKED_TOKENS_KEY, trackedTokensByUserAddressJSONString); + }, +}; diff --git a/packages/website/ts/local_storage/trade_history_storage.tsx b/packages/website/ts/local_storage/trade_history_storage.tsx new file mode 100644 index 000000000..dd5872609 --- /dev/null +++ b/packages/website/ts/local_storage/trade_history_storage.tsx @@ -0,0 +1,94 @@ +import * as _ from 'lodash'; +import {Fill} from 'ts/types'; +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; +import {localStorage} from 'ts/local_storage/local_storage'; +import ethUtil = require('ethereumjs-util'); +import BigNumber from 'bignumber.js'; + +const FILLS_KEY = 'fills'; +const FILLS_LATEST_BLOCK = 'fillsLatestBlock'; +const FILL_CLEAR_KEY = 'lastClearFillDate'; + +export const tradeHistoryStorage = { + // Clear all fill related localStorage if we've updated the config variable in an update + // that introduced a backward incompatible change requiring the user to re-fetch the fills from + // the blockchain + clearIfRequired() { + const lastClearFillDate = localStorage.getItemIfExists(FILL_CLEAR_KEY); + if (lastClearFillDate !== configs.lastLocalStorageFillClearanceDate) { + const localStorageKeys = localStorage.getAllKeys(); + _.each(localStorageKeys, key => { + if (_.startsWith(key, `${FILLS_KEY}-`) || _.startsWith(key, `${FILLS_LATEST_BLOCK}-`)) { + localStorage.removeItem(key); + } + }); + } + localStorage.setItem(FILL_CLEAR_KEY, configs.lastLocalStorageFillClearanceDate); + }, + addFillToUser(userAddress: string, networkId: number, fill: Fill) { + const fillsByHash = this.getUserFillsByHash(userAddress, networkId); + const fillHash = this._getFillHash(fill); + const doesFillExist = !_.isUndefined(fillsByHash[fillHash]); + if (doesFillExist) { + return; // noop + } + fillsByHash[fillHash] = fill; + const userFillsJSONString = JSON.stringify(fillsByHash); + const userFillsKey = this._getUserFillsKey(userAddress, networkId); + localStorage.setItem(userFillsKey, userFillsJSONString); + }, + removeFillFromUser(userAddress: string, networkId: number, fill: Fill) { + const fillsByHash = this.getUserFillsByHash(userAddress, networkId); + const fillHash = this._getFillHash(fill); + const doesFillExist = !_.isUndefined(fillsByHash[fillHash]); + if (!doesFillExist) { + return; // noop + } + delete fillsByHash[fillHash]; + const userFillsJSONString = JSON.stringify(fillsByHash); + const userFillsKey = this._getUserFillsKey(userAddress, networkId); + localStorage.setItem(userFillsKey, userFillsJSONString); + }, + getUserFillsByHash(userAddress: string, networkId: number): {[fillHash: string]: Fill} { + const userFillsKey = this._getUserFillsKey(userAddress, networkId); + const userFillsJSONString = localStorage.getItemIfExists(userFillsKey); + if (_.isEmpty(userFillsJSONString)) { + return {}; + } + const userFillsByHash = JSON.parse(userFillsJSONString); + _.each(userFillsByHash, (fill, hash) => { + fill.paidMakerFee = new BigNumber(fill.paidMakerFee); + fill.paidTakerFee = new BigNumber(fill.paidTakerFee); + fill.filledTakerTokenAmount = new BigNumber(fill.filledTakerTokenAmount); + fill.filledMakerTokenAmount = new BigNumber(fill.filledMakerTokenAmount); + }); + return userFillsByHash; + }, + getFillsLatestBlock(userAddress: string, networkId: number): number { + const userFillsLatestBlockKey = this._getFillsLatestBlockKey(userAddress, networkId); + const blockNumberStr = localStorage.getItemIfExists(userFillsLatestBlockKey); + if (_.isEmpty(blockNumberStr)) { + return constants.GENESIS_ORDER_BLOCK_BY_NETWORK_ID[networkId]; + } + const blockNumber = _.parseInt(blockNumberStr); + return blockNumber; + }, + setFillsLatestBlock(userAddress: string, networkId: number, blockNumber: number) { + const userFillsLatestBlockKey = this._getFillsLatestBlockKey(userAddress, networkId); + localStorage.setItem(userFillsLatestBlockKey, `${blockNumber}`); + }, + _getUserFillsKey(userAddress: string, networkId: number) { + const userFillsKey = `${FILLS_KEY}-${userAddress}-${networkId}`; + return userFillsKey; + }, + _getFillsLatestBlockKey(userAddress: string, networkId: number) { + const userFillsLatestBlockKey = `${FILLS_LATEST_BLOCK}-${userAddress}-${networkId}`; + return userFillsLatestBlockKey; + }, + _getFillHash(fill: Fill): string { + const fillJSON = JSON.stringify(fill); + const fillHash = ethUtil.sha256(fillJSON); + return fillHash.toString('hex'); + }, +}; diff --git a/packages/website/ts/pages/about/about.tsx b/packages/website/ts/pages/about/about.tsx new file mode 100644 index 000000000..8859fb00a --- /dev/null +++ b/packages/website/ts/pages/about/about.tsx @@ -0,0 +1,253 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as DocumentTitle from 'react-document-title'; +import RaisedButton from 'material-ui/RaisedButton'; +import {colors} from 'material-ui/styles'; +import {Styles, ProfileInfo} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {Link} from 'react-router-dom'; +import {Footer} from 'ts/components/footer'; +import {TopBar} from 'ts/components/top_bar'; +import {Question} from 'ts/pages/faq/question'; +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; +import {Profile} from 'ts/pages/about/profile'; + +const CUSTOM_BACKGROUND_COLOR = '#F0F0F0'; +const CUSTOM_GRAY = '#4C4C4C'; +const CUSTOM_LIGHT_GRAY = '#A2A2A2'; + +const teamRow1: ProfileInfo[] = [ + { + name: 'Will Warren', + title: 'Co-founder & CEO', + description: `Smart contract R&D. Previously applied physics at Los Alamos \ + Nat Lab. Mechanical engineering at UC San Diego. PhD dropout.`, + image: '/images/team/will.jpg', + linkedIn: 'https://www.linkedin.com/in/will-warren-92aab62b/', + github: 'https://github.com/willwarren89', + medium: 'https://medium.com/@willwarren89', + }, + { + name: 'Amir Bandeali', + title: 'Co-founder & CTO', + description: `Smart contract R&D. Previously fixed income trader at DRW. \ + Finance at University of Illinois, Urbana-Champaign.`, + image: '/images/team/amir.jpeg', + linkedIn: 'https://www.linkedin.com/in/abandeali1/', + github: 'https://github.com/abandeali1', + medium: 'https://medium.com/@abandeali1', + }, + { + name: 'Fabio Berger', + title: 'Senior Engineer', + description: `Full-stack blockchain engineer. Previously software engineer \ + at Airtable and founder of WealthLift. Computer science at Duke.`, + image: '/images/team/fabio.jpg', + linkedIn: 'https://www.linkedin.com/in/fabio-berger-03ab261a/', + github: 'https://github.com/fabioberger', + medium: 'https://medium.com/@fabioberger', + }, + { + name: 'Alex Xu', + title: 'Director of Operations', + description: `Strategy and operations. Previously digital marketing at Google \ + and vendor management at Amazon. Economics at UC San Diego.`, + image: '/images/team/alex.jpg', + linkedIn: 'https://www.linkedin.com/in/alex-xu/', + github: '', + medium: '', + }, +]; + +const teamRow2: ProfileInfo[] = [ + { + name: 'Leonid Logvinov', + title: 'Engineer', + description: `Full-stack blockchain engineer. Previously blockchain engineer \ + at Neufund. Computer science at University of Warsaw.`, + image: '/images/team/leonid.png', + linkedIn: 'https://www.linkedin.com/in/leonidlogvinov/', + github: 'https://github.com/LogvinovLeon', + medium: '', + }, + { + name: 'Ben Burns', + title: 'Designer', + description: `Product, motion, and graphic designer. Previously designer \ + at Airtable and Apple. Digital Design at University of Cincinnati.`, + image: '/images/team/ben.jpg', + linkedIn: 'https://www.linkedin.com/in/ben-burns-30170478/', + github: '', + medium: '', + }, + { + name: 'Philippe Castonguay', + title: 'Dev Relations Manager', + description: `Developer relations. Previously computational neuroscience \ + research at Janelia. Statistics at Western University. MA Dropout.`, + image: '/images/team/philippe.png', + linkedIn: '', + github: 'https://github.com/PhABC', + medium: '', + }, + { + name: 'Brandon Millman', + title: 'Senior Engineer', + description: `Full-stack engineer. Previously senior software engineer at \ + Twitter. Electrical and Computer Engineering at Duke.`, + image: '/images/team/brandon.png', + linkedIn: 'https://www.linkedin.com/company-beta/17942619/', + }, +]; + +const advisors: ProfileInfo[] = [ + { + name: 'Fred Ehrsam', + description: 'Co-founder of Coinbase. Previously FX trader at Goldman Sachs.', + image: '/images/advisors/fred.jpg', + linkedIn: 'https://www.linkedin.com/in/fredehrsam/', + medium: 'https://medium.com/@FEhrsam', + twitter: 'https://twitter.com/FEhrsam', + }, + { + name: 'Olaf Carlson-Wee', + image: '/images/advisors/olaf.png', + description: 'Founder of Polychain Capital. First hire at Coinbase. Angel investor.', + linkedIn: 'https://www.linkedin.com/in/olafcw/', + angellist: 'https://angel.co/olafcw', + }, + { + name: 'Joey Krug', + description: `Co-CIO at Pantera Capital. Founder of Augur. Thiel 20 Under 20 Fellow.`, + image: '/images/advisors/joey.jpg', + linkedIn: 'https://www.linkedin.com/in/joeykrug/', + github: 'https://github.com/joeykrug', + angellist: 'https://angel.co/joeykrug', + }, + { + name: 'Linda Xie', + description: 'Co-founder of Scalar Capital. Previously PM at Coinbase.', + image: '/images/advisors/linda.jpg', + linkedIn: 'https://www.linkedin.com/in/lindaxie/', + medium: 'https://medium.com/@linda.xie', + twitter: 'https://twitter.com/ljxie', + }, +]; + +export interface AboutProps { + source: string; + location: Location; +} + +interface AboutState {} + +const styles: Styles = { + header: { + fontFamily: 'Roboto Mono', + fontSize: 36, + color: 'black', + paddingTop: 110, + }, +}; + +export class About extends React.Component<AboutProps, AboutState> { + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + return ( + <div style={{backgroundColor: CUSTOM_BACKGROUND_COLOR}}> + <DocumentTitle title="0x About Us"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + style={{backgroundColor: CUSTOM_BACKGROUND_COLOR}} + /> + <div + id="about" + className="mx-auto max-width-4 py4" + style={{color: colors.grey800}} + > + <div + className="mx-auto pb4 sm-px3" + style={{maxWidth: 435}} + > + <div + style={styles.header} + > + About us: + </div> + <div + className="pt3" + style={{fontSize: 17, color: CUSTOM_GRAY, lineHeight: 1.5}} + > + Our team is a diverse and globally distributed group with backgrounds + in engineering, research, business and design. We are passionate about + decentralized technology and its potential to act as an equalizing force + in the world. + </div> + </div> + <div className="pt3 md-px4 lg-px0"> + <div className="clearfix pb3"> + {this.renderProfiles(teamRow1)} + </div> + <div className="clearfix"> + {this.renderProfiles(teamRow2)} + </div> + </div> + <div className="pt3 pb2"> + <div + className="pt2 pb3 sm-center md-pl4 lg-pl0 md-ml3" + style={{color: CUSTOM_LIGHT_GRAY, fontSize: 24, fontFamily: 'Roboto Mono'}} + > + Advisors: + </div> + <div className="clearfix"> + {this.renderProfiles(advisors)} + </div> + </div> + <div className="mx-auto py4 sm-px3" style={{maxWidth: 308}}> + <div + className="pb2" + style={{fontSize: 30, color: CUSTOM_GRAY, fontFamily: 'Roboto Mono', letterSpacing: 7.5}} + > + WE'RE HIRING + </div> + <div + className="pb4 mb4" + style={{fontSize: 16, color: CUSTOM_GRAY, lineHeight: 1.5, letterSpacing: '0.5px'}} + > + We are seeking outstanding candidates to{' '} + <a + href={constants.ANGELLIST_URL} + target="_blank" + style={{color: 'black'}} + > + join our team + </a> + . We value passion, diversity and unique perspectives. + </div> + </div> + </div> + <Footer location={this.props.location} /> + </div> + ); + } + private renderProfiles(profiles: ProfileInfo[]) { + const numIndiv = profiles.length; + const colSize = utils.getColSize(profiles.length); + return _.map(profiles, profile => { + return ( + <div + key={`profile-${profile.name}`} + > + <Profile + colSize={colSize} + profileInfo={profile} + /> + </div> + ); + }); + } +} diff --git a/packages/website/ts/pages/about/profile.tsx b/packages/website/ts/pages/about/profile.tsx new file mode 100644 index 000000000..6c48a8553 --- /dev/null +++ b/packages/website/ts/pages/about/profile.tsx @@ -0,0 +1,99 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {utils} from 'ts/utils/utils'; +import {Element as ScrollElement} from 'react-scroll'; +import {Styles, ProfileInfo} from 'ts/types'; + +const IMAGE_DIMENSION = 149; +const styles: Styles = { + subheader: { + textTransform: 'uppercase', + fontSize: 32, + margin: 0, + }, + imageContainer: { + width: IMAGE_DIMENSION, + height: IMAGE_DIMENSION, + boxShadow: 'rgba(0, 0, 0, 0.19) 2px 5px 10px', + }, +}; + +interface ProfileProps { + colSize: number; + profileInfo: ProfileInfo; +} + +export function Profile(props: ProfileProps) { + return ( + <div + className={`lg-col md-col lg-col-${props.colSize} md-col-6`} + > + <div + style={{maxWidth: 300}} + className="mx-auto lg-px3 md-px3 sm-px4 sm-pb3" + > + <div + className="circle overflow-hidden mx-auto" + style={styles.imageContainer} + > + <img + width={IMAGE_DIMENSION} + src={props.profileInfo.image} + /> + </div> + <div + className="center" + style={{fontSize: 18, fontWeight: 'bold', paddingTop: 20}} + > + {props.profileInfo.name} + </div> + {!_.isUndefined(props.profileInfo.title) && + <div + className="pt1 center" + style={{fontSize: 14, fontFamily: 'Roboto Mono', color: '#818181'}} + > + {props.profileInfo.title.toUpperCase()} + </div> + } + <div + style={{minHeight: 60, lineHeight: 1.4}} + className="pt1 pb2 mx-auto lg-h6 md-h6 sm-h5 sm-center" + > + {props.profileInfo.description} + </div> + <div className="flex pb3 mx-auto sm-hide xs-hide" style={{width: 180, opacity: 0.5}}> + {renderSocialMediaIcons(props.profileInfo)} + </div> + </div> + </div> + ); +} + +function renderSocialMediaIcons(profileInfo: ProfileInfo) { + const icons = [ + renderSocialMediaIcon('zmdi-github-box', profileInfo.github), + renderSocialMediaIcon('zmdi-linkedin-box', profileInfo.linkedIn), + renderSocialMediaIcon('zmdi-twitter-box', profileInfo.twitter), + ]; + return icons; +} + +function renderSocialMediaIcon(iconName: string, url: string) { + if (_.isEmpty(url)) { + return null; + } + + return ( + <div key={url} className="pr1"> + <a + href={url} + style={{color: 'inherit'}} + target="_blank" + className="text-decoration-none" + > + <i className={`zmdi ${iconName}`} style={{...styles.socalIcon}} /> + </a> + </div> + ); +} diff --git a/packages/website/ts/pages/documentation/comment.tsx b/packages/website/ts/pages/documentation/comment.tsx new file mode 100644 index 000000000..78bbdc069 --- /dev/null +++ b/packages/website/ts/pages/documentation/comment.tsx @@ -0,0 +1,24 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import {MarkdownCodeBlock} from 'ts/pages/shared/markdown_code_block'; + +interface CommentProps { + comment: string; + className?: string; +} + +const defaultProps = { + className: '', +}; + +export const Comment: React.SFC<CommentProps> = (props: CommentProps) => { + return ( + <div className={`${props.className} comment`}> + <ReactMarkdown + source={props.comment} + renderers={{CodeBlock: MarkdownCodeBlock}} + /> + </div> + ); +}; diff --git a/packages/website/ts/pages/documentation/custom_enum.tsx b/packages/website/ts/pages/documentation/custom_enum.tsx new file mode 100644 index 000000000..aca8af832 --- /dev/null +++ b/packages/website/ts/pages/documentation/custom_enum.tsx @@ -0,0 +1,31 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {utils} from 'ts/utils/utils'; +import {CustomType} from 'ts/types'; + +const STRING_ENUM_CODE_PREFIX = ' strEnum('; + +interface CustomEnumProps { + type: CustomType; +} + +// This component renders custom string enums that was a work-around for versions of +// TypeScript <2.4.0 that did not support them natively. We keep it around to support +// older versions of 0x.js <0.9.0 +export function CustomEnum(props: CustomEnumProps) { + const type = props.type; + if (!_.startsWith(type.defaultValue, STRING_ENUM_CODE_PREFIX)) { + utils.consoleLog('We do not yet support `Variable` types that are not strEnums'); + return null; + } + // Remove the prefix and postfix, leaving only the strEnum values without quotes. + const enumValues = type.defaultValue.slice(10, -3).replace(/'/g, ''); + return ( + <span> + {`{`} + {'\t'}{enumValues} + <br /> + {`}`} + </span> + ); +} diff --git a/packages/website/ts/pages/documentation/enum.tsx b/packages/website/ts/pages/documentation/enum.tsx new file mode 100644 index 000000000..9364a5d31 --- /dev/null +++ b/packages/website/ts/pages/documentation/enum.tsx @@ -0,0 +1,26 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {utils} from 'ts/utils/utils'; +import {TypeDocNode, EnumValue} from 'ts/types'; + +const STRING_ENUM_CODE_PREFIX = ' strEnum('; + +interface EnumProps { + values: EnumValue[]; +} + +export function Enum(props: EnumProps) { + const values = _.map(props.values, (value, i) => { + const isLast = i === props.values.length - 1; + const defaultValueIfAny = !_.isUndefined(value.defaultValue) ? ` = ${value.defaultValue}` : ''; + return `\n\t${value.name}${defaultValueIfAny},`; + }); + return ( + <span> + {`{`} + {values} + <br /> + {`}`} + </span> + ); +} diff --git a/packages/website/ts/pages/documentation/event_definition.tsx b/packages/website/ts/pages/documentation/event_definition.tsx new file mode 100644 index 000000000..58271e98f --- /dev/null +++ b/packages/website/ts/pages/documentation/event_definition.tsx @@ -0,0 +1,80 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import {Event, EventArg, HeaderSizes} from 'ts/types'; +import {Type} from 'ts/pages/documentation/type'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; + +const KEYWORD_COLOR = '#a81ca6'; +const CUSTOM_GREEN = 'rgb(77, 162, 75)'; + +interface EventDefinitionProps { + event: Event; +} + +interface EventDefinitionState { + shouldShowAnchor: boolean; +} + +export class EventDefinition extends React.Component<EventDefinitionProps, EventDefinitionState> { + constructor(props: EventDefinitionProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const event = this.props.event; + return ( + <div + id={event.name} + className="pb2" + style={{overflow: 'hidden', width: '100%'}} + onMouseOver={this.setAnchorVisibility.bind(this, true)} + onMouseOut={this.setAnchorVisibility.bind(this, false)} + > + <AnchorTitle + headerSize={HeaderSizes.H3} + title={`Event ${event.name}`} + id={event.name} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + <div style={{fontSize: 16}}> + <pre> + <code className="hljs"> + {this.renderEventCode()} + </code> + </pre> + </div> + </div> + ); + } + private renderEventCode() { + const indexed = <span style={{color: CUSTOM_GREEN}}> indexed</span>; + const eventArgs = _.map(this.props.event.eventArgs, (eventArg: EventArg) => { + return ( + <span key={`eventArg-${eventArg.name}`}> + {eventArg.name}{eventArg.isIndexed ? indexed : ''}: <Type type={eventArg.type} />, + </span> + ); + }); + const argList = _.reduce(eventArgs, (prev: React.ReactNode, curr: React.ReactNode) => { + return [prev, '\n\t', curr]; + }); + return ( + <span> + {`{`} + <br /> + {'\t'}{argList} + <br /> + {`}`} + </span> + ); + } + private setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/website/ts/pages/documentation/interface.tsx b/packages/website/ts/pages/documentation/interface.tsx new file mode 100644 index 000000000..9e40b8901 --- /dev/null +++ b/packages/website/ts/pages/documentation/interface.tsx @@ -0,0 +1,54 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {CustomType, TypeDocTypes} from 'ts/types'; +import {Type} from 'ts/pages/documentation/type'; +import {MethodSignature} from 'ts/pages/documentation/method_signature'; + +interface InterfaceProps { + type: CustomType; +} + +export function Interface(props: InterfaceProps) { + const type = props.type; + const properties = _.map(type.children, property => { + return ( + <span key={`property-${property.name}-${property.type}-${type.name}`}> + {property.name}:{' '} + {property.type.typeDocType !== TypeDocTypes.Reflection ? + <Type type={property.type} /> : + <MethodSignature + method={property.type.method} + shouldHideMethodName={true} + shouldUseArrowSyntax={true} + /> + }, + </span> + ); + }); + const hasIndexSignature = !_.isUndefined(type.indexSignature); + if (hasIndexSignature) { + const is = type.indexSignature; + const param = ( + <span key={`indexSigParams-${is.keyName}-${is.keyType}-${type.name}`}> + {is.keyName}: <Type type={is.keyType} /> + </span> + ); + properties.push(( + <span key={`indexSignature-${type.name}-${is.keyType.name}`}> + [{param}]: {is.valueName}, + </span> + )); + } + const propertyList = _.reduce(properties, (prev: React.ReactNode, curr: React.ReactNode) => { + return [prev, '\n\t', curr]; + }); + return ( + <span> + {`{`} + <br /> + {'\t'}{propertyList} + <br /> + {`}`} + </span> + ); +} diff --git a/packages/website/ts/pages/documentation/method_block.tsx b/packages/website/ts/pages/documentation/method_block.tsx new file mode 100644 index 000000000..e31c75ffd --- /dev/null +++ b/packages/website/ts/pages/documentation/method_block.tsx @@ -0,0 +1,173 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import {Chip} from 'material-ui/Chip'; +import {colors} from 'material-ui/styles'; +import { + TypeDocNode, + Styles, + TypeDefinitionByName, + TypescriptMethod, + SolidityMethod, + Parameter, + HeaderSizes, +} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {SourceLink} from 'ts/pages/documentation/source_link'; +import {MethodSignature} from 'ts/pages/documentation/method_signature'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {Comment} from 'ts/pages/documentation/comment'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; + +interface MethodBlockProps { + method: SolidityMethod|TypescriptMethod; + libraryVersion: string; + typeDefinitionByName: TypeDefinitionByName; +} + +interface MethodBlockState { + shouldShowAnchor: boolean; +} + +const styles: Styles = { + chip: { + fontSize: 13, + backgroundColor: colors.lightBlueA700, + color: 'white', + height: 11, + borderRadius: 14, + marginTop: 19, + lineHeight: 0.8, + }, +}; + +export class MethodBlock extends React.Component<MethodBlockProps, MethodBlockState> { + constructor(props: MethodBlockProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const method = this.props.method; + if (typeDocUtils.isPrivateOrProtectedProperty(method.name)) { + return null; + } + + return ( + <div + id={method.name} + style={{overflow: 'hidden', width: '100%'}} + className="pb4" + onMouseOver={this.setAnchorVisibility.bind(this, true)} + onMouseOut={this.setAnchorVisibility.bind(this, false)} + > + {!method.isConstructor && + <div className="flex"> + {(method as TypescriptMethod).isStatic && + this.renderChip('Static') + } + {(method as SolidityMethod).isConstant && + this.renderChip('Constant') + } + {(method as SolidityMethod).isPayable && + this.renderChip('Payable') + } + <AnchorTitle + headerSize={HeaderSizes.H3} + title={method.name} + id={method.name} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + </div> + } + <code className="hljs"> + <MethodSignature + method={method} + typeDefinitionByName={this.props.typeDefinitionByName} + /> + </code> + {(method as TypescriptMethod).source && + <SourceLink + version={this.props.libraryVersion} + source={(method as TypescriptMethod).source} + /> + } + {method.comment && + <Comment + comment={method.comment} + className="py2" + /> + } + {method.parameters && !_.isEmpty(method.parameters) && + <div> + <h4 + className="pb1 thin" + style={{borderBottom: '1px solid #e1e8ed'}} + > + ARGUMENTS + </h4> + {this.renderParameterDescriptions(method.parameters)} + </div> + } + {method.returnComment && + <div className="pt1 comment"> + <h4 + className="pb1 thin" + style={{borderBottom: '1px solid #e1e8ed'}} + > + RETURNS + </h4> + <Comment + comment={method.returnComment} + /> + </div> + } + </div> + ); + } + private renderChip(text: string) { + return ( + <div + className="p1 mr1" + style={styles.chip} + > + {text} + </div> + ); + } + private renderParameterDescriptions(parameters: Parameter[]) { + const descriptions = _.map(parameters, parameter => { + const isOptional = parameter.isOptional; + return ( + <div + key={`param-description-${parameter.name}`} + className="flex pb1 mb2" + style={{borderBottom: '1px solid #f0f4f7'}} + > + <div className="pl2 col lg-col-4 md-col-4 sm-col-12 col-12"> + <div className="bold"> + {parameter.name} + </div> + <div className="pt1" style={{color: colors.grey500, fontSize: 14}}> + {isOptional && 'optional'} + </div> + </div> + <div className="col lg-col-8 md-col-8 sm-col-12 col-12"> + {parameter.comment && + <Comment + comment={parameter.comment} + /> + } + </div> + </div> + ); + }); + return descriptions; + } + private setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/website/ts/pages/documentation/method_signature.tsx b/packages/website/ts/pages/documentation/method_signature.tsx new file mode 100644 index 000000000..3b5d2ce78 --- /dev/null +++ b/packages/website/ts/pages/documentation/method_signature.tsx @@ -0,0 +1,62 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {TypescriptMethod, SolidityMethod, TypeDefinitionByName, Parameter} from 'ts/types'; +import {Type} from 'ts/pages/documentation/type'; + +interface MethodSignatureProps { + method: TypescriptMethod|SolidityMethod; + shouldHideMethodName?: boolean; + shouldUseArrowSyntax?: boolean; + typeDefinitionByName?: TypeDefinitionByName; +} + +const defaultProps = { + shouldHideMethodName: false, + shouldUseArrowSyntax: false, +}; + +export const MethodSignature: React.SFC<MethodSignatureProps> = (props: MethodSignatureProps) => { + const parameters = renderParameters(props.method, props.typeDefinitionByName); + const paramString = _.reduce(parameters, (prev: React.ReactNode, curr: React.ReactNode) => { + return [prev, ', ', curr]; + }); + const methodName = props.shouldHideMethodName ? '' : props.method.name; + const typeParameterIfExists = _.isUndefined((props.method as TypescriptMethod).typeParameter) ? + undefined : + renderTypeParameter(props.method, props.typeDefinitionByName); + return ( + <span> + {props.method.callPath}{methodName}{typeParameterIfExists}({paramString}) + {props.shouldUseArrowSyntax ? ' => ' : ': '} + {' '} + {props.method.returnType && + <Type type={props.method.returnType} typeDefinitionByName={props.typeDefinitionByName}/> + } + </span> + ); +}; + +function renderParameters(method: TypescriptMethod|SolidityMethod, typeDefinitionByName?: TypeDefinitionByName) { + const parameters = method.parameters; + const params = _.map(parameters, (p: Parameter) => { + const isOptional = p.isOptional; + return ( + <span key={`param-${p.type}-${p.name}`}> + {p.name}{isOptional && '?'}: <Type type={p.type} typeDefinitionByName={typeDefinitionByName}/> + </span> + ); + }); + return params; +} + +function renderTypeParameter(method: TypescriptMethod, typeDefinitionByName?: TypeDefinitionByName) { + const typeParameter = method.typeParameter; + const typeParam = ( + <span> + {`<${typeParameter.name} extends `} + <Type type={typeParameter.type} typeDefinitionByName={typeDefinitionByName}/> + {`>`} + </span> + ); + return typeParam; +} diff --git a/packages/website/ts/pages/documentation/smart_contracts_documentation.tsx b/packages/website/ts/pages/documentation/smart_contracts_documentation.tsx new file mode 100644 index 000000000..3e97829c4 --- /dev/null +++ b/packages/website/ts/pages/documentation/smart_contracts_documentation.tsx @@ -0,0 +1,401 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import DocumentTitle = require('react-document-title'); +import findVersions = require('find-versions'); +import semverSort = require('semver-sort'); +import {colors} from 'material-ui/styles'; +import CircularProgress from 'material-ui/CircularProgress'; +import { + scroller, +} from 'react-scroll'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + SmartContractsDocSections, + Styles, + DoxityDocObj, + TypeDefinitionByName, + DocAgnosticFormat, + SolidityMethod, + Property, + CustomType, + MenuSubsectionsBySection, + Event, + Docs, + AddressByContractName, + Networks, + EtherscanLinkSuffixes, +} from 'ts/types'; +import {TopBar} from 'ts/components/top_bar'; +import {utils} from 'ts/utils/utils'; +import {docUtils} from 'ts/utils/doc_utils'; +import {constants} from 'ts/utils/constants'; +import {MethodBlock} from 'ts/pages/documentation/method_block'; +import {SourceLink} from 'ts/pages/documentation/source_link'; +import {Type} from 'ts/pages/documentation/type'; +import {TypeDefinition} from 'ts/pages/documentation/type_definition'; +import {MarkdownSection} from 'ts/pages/shared/markdown_section'; +import {Comment} from 'ts/pages/documentation/comment'; +import {Badge} from 'ts/components/ui/badge'; +import {EventDefinition} from 'ts/pages/documentation/event_definition'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {SectionHeader} from 'ts/pages/shared/section_header'; +import {NestedSidebarMenu} from 'ts/pages/shared/nested_sidebar_menu'; +import {doxityUtils} from 'ts/utils/doxity_utils'; +/* tslint:disable:no-var-requires */ +const IntroMarkdown = require('md/docs/smart_contracts/introduction'); +/* tslint:enable:no-var-requires */ + +const SCROLL_TO_TIMEOUT = 500; +const CUSTOM_PURPLE = '#690596'; +const CUSTOM_RED = '#e91751'; +const CUSTOM_TURQUOIS = '#058789'; +const DOC_JSON_ROOT = constants.S3_SMART_CONTRACTS_DOCUMENTATION_JSON_ROOT; + +const sectionNameToMarkdown = { + [SmartContractsDocSections.Introduction]: IntroMarkdown, +}; +const networkNameToColor: {[network: string]: string} = { + [Networks.kovan]: CUSTOM_PURPLE, + [Networks.ropsten]: CUSTOM_RED, + [Networks.mainnet]: CUSTOM_TURQUOIS, +}; + +export interface SmartContractsDocumentationAllProps { + source: string; + location: Location; + dispatcher: Dispatcher; + docsVersion: string; + availableDocVersions: string[]; +} + +interface SmartContractsDocumentationState { + docAgnosticFormat?: DocAgnosticFormat; +} + +const styles: Styles = { + mainContainers: { + position: 'absolute', + top: 60, + left: 0, + bottom: 0, + right: 0, + overflowZ: 'hidden', + overflowY: 'scroll', + minHeight: 'calc(100vh - 60px)', + WebkitOverflowScrolling: 'touch', + }, + menuContainer: { + borderColor: colors.grey300, + maxWidth: 330, + marginLeft: 20, + }, +}; + +export class SmartContractsDocumentation extends + React.Component<SmartContractsDocumentationAllProps, SmartContractsDocumentationState> { + constructor(props: SmartContractsDocumentationAllProps) { + super(props); + this.state = { + docAgnosticFormat: undefined, + }; + } + public componentWillMount() { + const pathName = this.props.location.pathname; + const lastSegment = pathName.substr(pathName.lastIndexOf('/') + 1); + const versions = findVersions(lastSegment); + const preferredVersionIfExists = versions.length > 0 ? versions[0] : undefined; + this.fetchJSONDocsFireAndForgetAsync(preferredVersionIfExists); + } + public render() { + const menuSubsectionsBySection = _.isUndefined(this.state.docAgnosticFormat) + ? {} + : this.getMenuSubsectionsBySection(this.state.docAgnosticFormat); + return ( + <div> + <DocumentTitle title="0x Smart Contract Documentation"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + docsVersion={this.props.docsVersion} + availableDocVersions={this.props.availableDocVersions} + menuSubsectionsBySection={menuSubsectionsBySection} + shouldFullWidth={true} + doc={Docs.SmartContracts} + /> + {_.isUndefined(this.state.docAgnosticFormat) ? + <div + className="col col-12" + style={styles.mainContainers} + > + <div + className="relative sm-px2 sm-pt2 sm-m1" + style={{height: 122, top: '50%', transform: 'translateY(-50%)'}} + > + <div className="center pb2"> + <CircularProgress size={40} thickness={5} /> + </div> + <div className="center pt2" style={{paddingBottom: 11}}>Loading documentation...</div> + </div> + </div> : + <div + className="mx-auto flex" + style={{color: colors.grey800, height: 43}} + > + <div className="relative col md-col-3 lg-col-3 lg-pl0 md-pl1 sm-hide xs-hide"> + <div + className="border-right absolute" + style={{...styles.menuContainer, ...styles.mainContainers}} + > + <NestedSidebarMenu + selectedVersion={this.props.docsVersion} + versions={this.props.availableDocVersions} + topLevelMenu={constants.menuSmartContracts} + menuSubsectionsBySection={menuSubsectionsBySection} + doc={Docs.SmartContracts} + /> + </div> + </div> + <div className="relative col lg-col-9 md-col-9 sm-col-12 col-12"> + <div + id="documentation" + style={styles.mainContainers} + className="absolute" + > + <div id="smartContractsDocs" /> + <h1 className="md-pl2 sm-pl3"> + <a href={constants.GITHUB_CONTRACTS_URL} target="_blank"> + 0x Smart Contracts + </a> + </h1> + {this.renderDocumentation()} + </div> + </div> + </div> + } + </div> + ); + } + private renderDocumentation(): React.ReactNode { + const subMenus = _.values(constants.menuSmartContracts); + const orderedSectionNames = _.flatten(subMenus); + // Since smart contract method params are all base types, no need to pass + // down the typeDefinitionByName + const typeDefinitionByName = {}; + const sections = _.map(orderedSectionNames, this.renderSection.bind(this, typeDefinitionByName)); + + return sections; + } + private renderSection(typeDefinitionByName: TypeDefinitionByName, sectionName: string): React.ReactNode { + const docSection = this.state.docAgnosticFormat[sectionName]; + + const markdownFileIfExists = sectionNameToMarkdown[sectionName]; + if (!_.isUndefined(markdownFileIfExists)) { + return ( + <MarkdownSection + key={`markdown-section-${sectionName}`} + sectionName={sectionName} + markdownContent={markdownFileIfExists} + /> + ); + } + + if (_.isUndefined(docSection)) { + return null; + } + + const sortedProperties = _.sortBy(docSection.properties, 'name'); + const propertyDefs = _.map(sortedProperties, this.renderProperty.bind(this)); + + const sortedMethods = _.sortBy(docSection.methods, 'name'); + const methodDefs = _.map(sortedMethods, method => { + const isConstructor = false; + return this.renderMethodBlocks(method, sectionName, isConstructor, typeDefinitionByName); + }); + + const sortedEvents = _.sortBy(docSection.events, 'name'); + const eventDefs = _.map(sortedEvents, (event: Event, i: number) => { + return ( + <EventDefinition + key={`event-${event.name}-${i}`} + event={event} + /> + ); + }); + return ( + <div + key={`section-${sectionName}`} + className="py2 pr3 md-pl2 sm-pl3" + > + <div className="flex"> + <div style={{marginRight: 7}}> + <SectionHeader sectionName={sectionName} /> + </div> + {this.renderNetworkBadges(sectionName)} + </div> + {docSection.comment && + <Comment + comment={docSection.comment} + /> + } + {docSection.constructors.length > 0 && + <div> + <h2 className="thin">Constructor</h2> + {this.renderConstructors(docSection.constructors, typeDefinitionByName)} + </div> + } + {docSection.properties.length > 0 && + <div> + <h2 className="thin">Properties</h2> + <div>{propertyDefs}</div> + </div> + } + {docSection.methods.length > 0 && + <div> + <h2 className="thin">Methods</h2> + <div>{methodDefs}</div> + </div> + } + {docSection.events.length > 0 && + <div> + <h2 className="thin">Events</h2> + <div>{eventDefs}</div> + </div> + } + </div> + ); + } + private renderNetworkBadges(sectionName: string) { + const networkToAddressByContractName = constants.contractAddresses[this.props.docsVersion]; + const badges = _.map(networkToAddressByContractName, + (addressByContractName: AddressByContractName, networkName: string) => { + const contractAddress = addressByContractName[sectionName]; + const linkIfExists = utils.getEtherScanLinkIfExists( + contractAddress, constants.networkIdByName[networkName], EtherscanLinkSuffixes.address, + ); + return ( + <a + key={`badge-${networkName}-${sectionName}`} + href={linkIfExists} + target="_blank" + style={{color: 'white', textDecoration: 'none'}} + > + <Badge + title={networkName} + backgroundColor={networkNameToColor[networkName]} + /> + </a> + ); + }); + return badges; + } + private renderConstructors(constructors: SolidityMethod[], + typeDefinitionByName: TypeDefinitionByName): React.ReactNode { + const constructorDefs = _.map(constructors, constructor => { + return this.renderMethodBlocks( + constructor, SmartContractsDocSections.zeroEx, constructor.isConstructor, typeDefinitionByName, + ); + }); + return ( + <div> + {constructorDefs} + </div> + ); + } + private renderProperty(property: Property): React.ReactNode { + return ( + <div + key={`property-${property.name}-${property.type.name}`} + className="pb3" + > + <code className="hljs"> + {property.name}: <Type type={property.type} /> + </code> + {property.source && + <SourceLink + version={this.props.docsVersion} + source={property.source} + /> + } + {property.comment && + <Comment + comment={property.comment} + className="py2" + /> + } + </div> + ); + } + private renderMethodBlocks(method: SolidityMethod, sectionName: string, isConstructor: boolean, + typeDefinitionByName: TypeDefinitionByName): React.ReactNode { + return ( + <MethodBlock + key={`method-${method.name}`} + method={method} + typeDefinitionByName={typeDefinitionByName} + libraryVersion={this.props.docsVersion} + /> + ); + } + private scrollToHash(): void { + const hashWithPrefix = this.props.location.hash; + let hash = hashWithPrefix.slice(1); + if (_.isEmpty(hash)) { + hash = 'smartContractsDocs'; // scroll to the top + } + + scroller.scrollTo(hash, {duration: 0, offset: 0, containerId: 'documentation'}); + } + private getMenuSubsectionsBySection(docAgnosticFormat?: DocAgnosticFormat): MenuSubsectionsBySection { + const menuSubsectionsBySection = {} as MenuSubsectionsBySection; + if (_.isUndefined(docAgnosticFormat)) { + return menuSubsectionsBySection; + } + + const docSections = _.keys(SmartContractsDocSections); + _.each(docSections, sectionName => { + const docSection = docAgnosticFormat[sectionName]; + if (_.isUndefined(docSection)) { + return; // no-op + } + + if (sectionName === SmartContractsDocSections.types) { + const sortedTypesNames = _.sortBy(docSection.types, 'name'); + const typeNames = _.map(sortedTypesNames, t => t.name); + menuSubsectionsBySection[sectionName] = typeNames; + } else { + const sortedEventNames = _.sortBy(docSection.events, 'name'); + const eventNames = _.map(sortedEventNames, m => m.name); + const sortedMethodNames = _.sortBy(docSection.methods, 'name'); + const methodNames = _.map(sortedMethodNames, m => m.name); + menuSubsectionsBySection[sectionName] = [...methodNames, ...eventNames]; + } + }); + return menuSubsectionsBySection; + } + private async fetchJSONDocsFireAndForgetAsync(preferredVersionIfExists?: string): Promise<void> { + const versionToFileName = await docUtils.getVersionToFileNameAsync(DOC_JSON_ROOT); + const versions = _.keys(versionToFileName); + this.props.dispatcher.updateAvailableDocVersions(versions); + const sortedVersions = semverSort.desc(versions); + const latestVersion = sortedVersions[0]; + + let versionToFetch = latestVersion; + if (!_.isUndefined(preferredVersionIfExists)) { + const preferredVersionFileNameIfExists = versionToFileName[preferredVersionIfExists]; + if (!_.isUndefined(preferredVersionFileNameIfExists)) { + versionToFetch = preferredVersionIfExists; + } + } + this.props.dispatcher.updateCurrentDocsVersion(versionToFetch); + + const versionFileNameToFetch = versionToFileName[versionToFetch]; + const versionDocObj = await docUtils.getJSONDocFileAsync(versionFileNameToFetch, DOC_JSON_ROOT); + const docAgnosticFormat = doxityUtils.convertToDocAgnosticFormat(versionDocObj as DoxityDocObj); + + this.setState({ + docAgnosticFormat, + }, () => { + this.scrollToHash(); + }); + } +} diff --git a/packages/website/ts/pages/documentation/source_link.tsx b/packages/website/ts/pages/documentation/source_link.tsx new file mode 100644 index 000000000..24009ce8a --- /dev/null +++ b/packages/website/ts/pages/documentation/source_link.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import {Source} from 'ts/types'; +import {constants} from 'ts/utils/constants'; + +interface SourceLinkProps { + source: Source; + version: string; +} + +const SUB_PKG = '0x.js'; + +export function SourceLink(props: SourceLinkProps) { + const src = props.source; + const url = constants.GITHUB_0X_JS_URL; + const sourceCodeUrl = `${url}/blob/${SUB_PKG}%40${props.version}/packages/${SUB_PKG}/${src.fileName}#L${src.line}`; + return ( + <div className="pt2" style={{fontSize: 14}}> + <a + href={sourceCodeUrl} + target="_blank" + className="underline" + style={{color: colors.grey500}} + > + Source + </a> + </div> + ); +} diff --git a/packages/website/ts/pages/documentation/type.tsx b/packages/website/ts/pages/documentation/type.tsx new file mode 100644 index 000000000..7d02d6804 --- /dev/null +++ b/packages/website/ts/pages/documentation/type.tsx @@ -0,0 +1,196 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Link as ScrollLink} from 'react-scroll'; +import * as ReactTooltip from 'react-tooltip'; +import {colors} from 'material-ui/styles'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; +import {constants} from 'ts/utils/constants'; +import {Type as TypeDef, TypeDocTypes, TypeDefinitionByName} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {TypeDefinition} from 'ts/pages/documentation/type_definition'; + +const BUILT_IN_TYPE_COLOR = '#e69d00'; +const STRING_LITERAL_COLOR = '#4da24b'; + +// Some types reference other libraries. For these types, we want to link the user to the relevant documentation. +const typeToUrl: {[typeName: string]: string} = { + Web3: constants.WEB3_DOCS_URL, + Provider: constants.WEB3_PROVIDER_DOCS_URL, + BigNumber: constants.BIGNUMBERJS_GITHUB_URL, + DecodedLogEntryEvent: constants.WEB3_DECODED_LOG_ENTRY_EVENT_URL, + LogEntryEvent: constants.WEB3_LOG_ENTRY_EVENT_URL, +}; + +const typePrefix: {[typeName: string]: string} = { + Provider: 'Web3', + DecodedLogEntryEvent: 'Web3', + LogEntryEvent: 'Web3', +}; + +const typeToSection: {[typeName: string]: string} = { + ExchangeWrapper: 'exchange', + TokenWrapper: 'token', + TokenRegistryWrapper: 'tokenRegistry', + EtherTokenWrapper: 'etherToken', + ProxyWrapper: 'proxy', + TokenTransferProxyWrapper: 'proxy', +}; + +interface TypeProps { + type: TypeDef; + typeDefinitionByName?: TypeDefinitionByName; +} + +// The return type needs to be `any` here so that we can recursively define <Type /> components within +// <Type /> components (e.g when rendering the union type). +export function Type(props: TypeProps): any { + const type = props.type; + const isIntrinsic = type.typeDocType === TypeDocTypes.Intrinsic; + const isReference = type.typeDocType === TypeDocTypes.Reference; + const isArray = type.typeDocType === TypeDocTypes.Array; + const isStringLiteral = type.typeDocType === TypeDocTypes.StringLiteral; + let typeNameColor = 'inherit'; + let typeName: string|React.ReactNode; + let typeArgs: React.ReactNode[] = []; + switch (type.typeDocType) { + case TypeDocTypes.Intrinsic: + case TypeDocTypes.Unknown: + typeName = type.name; + typeNameColor = BUILT_IN_TYPE_COLOR; + break; + + case TypeDocTypes.Reference: + typeName = type.name; + typeArgs = _.map(type.typeArguments, (arg: TypeDef) => { + if (arg.typeDocType === TypeDocTypes.Array) { + const key = `type-${arg.elementType.name}-${arg.elementType.typeDocType}`; + return ( + <span> + <Type + key={key} + type={arg.elementType} + typeDefinitionByName={props.typeDefinitionByName} + />[] + </span> + ); + } else { + const subType = ( + <Type + key={`type-${arg.name}-${arg.value}-${arg.typeDocType}`} + type={arg} + typeDefinitionByName={props.typeDefinitionByName} + /> + ); + return subType; + } + }); + break; + + case TypeDocTypes.StringLiteral: + typeName = `'${type.value}'`; + typeNameColor = STRING_LITERAL_COLOR; + break; + + case TypeDocTypes.Array: + typeName = type.elementType.name; + break; + + case TypeDocTypes.Union: + const unionTypes = _.map(type.types, t => { + return ( + <Type + key={`type-${t.name}-${t.value}-${t.typeDocType}`} + type={t} + typeDefinitionByName={props.typeDefinitionByName} + /> + ); + }); + typeName = _.reduce(unionTypes, (prev: React.ReactNode, curr: React.ReactNode) => { + return [prev, '|', curr]; + }); + break; + + case TypeDocTypes.TypeParameter: + typeName = type.name; + break; + + default: + throw utils.spawnSwitchErr('type.typeDocType', type.typeDocType); + } + // HACK: Normalize BigNumber to simply BigNumber. For some reason the type + // name is unpredictably one or the other. + if (typeName === 'BigNumber') { + typeName = 'BigNumber'; + } + const commaSeparatedTypeArgs = _.reduce(typeArgs, (prev: React.ReactNode, curr: React.ReactNode) => { + return [prev, ', ', curr]; + }); + + const typeNameUrlIfExists = typeToUrl[(typeName as string)]; + const typePrefixIfExists = typePrefix[(typeName as string)]; + const sectionNameIfExists = typeToSection[(typeName as string)]; + if (!_.isUndefined(typeNameUrlIfExists)) { + typeName = ( + <a + href={typeNameUrlIfExists} + target="_blank" + className="text-decoration-none" + style={{color: colors.lightBlueA700}} + > + {!_.isUndefined(typePrefixIfExists) ? `${typePrefixIfExists}.` : ''}{typeName} + </a> + ); + } else if ((isReference || isArray) && + (typeDocUtils.isPublicType(typeName as string) || + !_.isUndefined(sectionNameIfExists))) { + const id = Math.random().toString(); + const typeDefinitionAnchorId = _.isUndefined(sectionNameIfExists) ? typeName : sectionNameIfExists; + let typeDefinition; + if (props.typeDefinitionByName) { + typeDefinition = props.typeDefinitionByName[typeName as string]; + } + typeName = ( + <ScrollLink + to={typeDefinitionAnchorId} + offset={0} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + {_.isUndefined(typeDefinition) || utils.isUserOnMobile() ? + <span + onClick={utils.setUrlHash.bind(null, typeDefinitionAnchorId)} + style={{color: colors.lightBlueA700, cursor: 'pointer'}} + > + {typeName} + </span> : + <span + data-tip={true} + data-for={id} + onClick={utils.setUrlHash.bind(null, typeDefinitionAnchorId)} + style={{color: colors.lightBlueA700, cursor: 'pointer', display: 'inline-block'}} + > + {typeName} + <ReactTooltip + type="light" + effect="solid" + id={id} + className="typeTooltip" + > + <TypeDefinition customType={typeDefinition} shouldAddId={false} /> + </ReactTooltip> + </span> + } + </ScrollLink> + ); + } + return ( + <span> + <span style={{color: typeNameColor}}>{typeName}</span> + {isArray && '[]'}{!_.isEmpty(typeArgs) && + <span> + {'<'}{commaSeparatedTypeArgs}{'>'} + </span> + } + </span> + ); +} diff --git a/packages/website/ts/pages/documentation/type_definition.tsx b/packages/website/ts/pages/documentation/type_definition.tsx new file mode 100644 index 000000000..bcb07be8e --- /dev/null +++ b/packages/website/ts/pages/documentation/type_definition.tsx @@ -0,0 +1,135 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import {KindString, CustomType, TypeDocTypes, CustomTypeChild, HeaderSizes} from 'ts/types'; +import {Type} from 'ts/pages/documentation/type'; +import {Interface} from 'ts/pages/documentation/interface'; +import {CustomEnum} from 'ts/pages/documentation/custom_enum'; +import {Enum} from 'ts/pages/documentation/enum'; +import {MethodSignature} from 'ts/pages/documentation/method_signature'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {Comment} from 'ts/pages/documentation/comment'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; + +const KEYWORD_COLOR = '#a81ca6'; + +interface TypeDefinitionProps { + customType: CustomType; + shouldAddId?: boolean; +} + +interface TypeDefinitionState { + shouldShowAnchor: boolean; +} + +export class TypeDefinition extends React.Component<TypeDefinitionProps, TypeDefinitionState> { + public static defaultProps: Partial<TypeDefinitionProps> = { + shouldAddId: true, + }; + constructor(props: TypeDefinitionProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const customType = this.props.customType; + if (!typeDocUtils.isPublicType(customType.name)) { + return null; // no-op + } + + let typePrefix: string; + let codeSnippet: React.ReactNode; + switch (customType.kindString) { + case KindString.Interface: + typePrefix = 'Interface'; + codeSnippet = ( + <Interface + type={customType} + /> + ); + break; + + case KindString.Variable: + typePrefix = 'Enum'; + codeSnippet = ( + <CustomEnum + type={customType} + /> + ); + break; + + case KindString.Enumeration: + typePrefix = 'Enum'; + const enumValues = _.map(customType.children, (c: CustomTypeChild) => { + return { + name: c.name, + defaultValue: c.defaultValue, + }; + }); + codeSnippet = ( + <Enum + values={enumValues} + /> + ); + break; + + case KindString['Type alias']: + typePrefix = 'Type Alias'; + codeSnippet = ( + <span> + <span style={{color: KEYWORD_COLOR}}>type</span> {customType.name} ={' '} + {customType.type.typeDocType !== TypeDocTypes.Reflection ? + <Type type={customType.type} /> : + <MethodSignature + method={customType.type.method} + shouldHideMethodName={true} + shouldUseArrowSyntax={true} + /> + } + </span> + ); + break; + + default: + throw utils.spawnSwitchErr('type.kindString', customType.kindString); + } + + const typeDefinitionAnchorId = customType.name; + return ( + <div + id={this.props.shouldAddId ? typeDefinitionAnchorId : ''} + className="pb2" + style={{overflow: 'hidden', width: '100%'}} + onMouseOver={this.setAnchorVisibility.bind(this, true)} + onMouseOut={this.setAnchorVisibility.bind(this, false)} + > + <AnchorTitle + headerSize={HeaderSizes.H3} + title={`${typePrefix} ${customType.name}`} + id={this.props.shouldAddId ? typeDefinitionAnchorId : ''} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + <div style={{fontSize: 16}}> + <pre> + <code className="hljs"> + {codeSnippet} + </code> + </pre> + </div> + {customType.comment && + <Comment + comment={customType.comment} + className="py2" + /> + } + </div> + ); + } + private setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/website/ts/pages/documentation/zero_ex_js_documentation.tsx b/packages/website/ts/pages/documentation/zero_ex_js_documentation.tsx new file mode 100644 index 000000000..c26fb7d0a --- /dev/null +++ b/packages/website/ts/pages/documentation/zero_ex_js_documentation.tsx @@ -0,0 +1,340 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import DocumentTitle = require('react-document-title'); +import findVersions = require('find-versions'); +import semverSort = require('semver-sort'); +import {colors} from 'material-ui/styles'; +import MenuItem from 'material-ui/MenuItem'; +import CircularProgress from 'material-ui/CircularProgress'; +import Paper from 'material-ui/Paper'; +import { + Link as ScrollLink, + Element as ScrollElement, + scroller, +} from 'react-scroll'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + KindString, + TypeDocNode, + ZeroExJsDocSections, + Styles, + ScreenWidths, + TypeDefinitionByName, + DocAgnosticFormat, + TypescriptMethod, + Property, + CustomType, + Docs, +} from 'ts/types'; +import {TopBar} from 'ts/components/top_bar'; +import {utils} from 'ts/utils/utils'; +import {docUtils} from 'ts/utils/doc_utils'; +import {constants} from 'ts/utils/constants'; +import {Loading} from 'ts/components/ui/loading'; +import {MethodBlock} from 'ts/pages/documentation/method_block'; +import {SourceLink} from 'ts/pages/documentation/source_link'; +import {Type} from 'ts/pages/documentation/type'; +import {TypeDefinition} from 'ts/pages/documentation/type_definition'; +import {MarkdownSection} from 'ts/pages/shared/markdown_section'; +import {Comment} from 'ts/pages/documentation/comment'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {SectionHeader} from 'ts/pages/shared/section_header'; +import {NestedSidebarMenu} from 'ts/pages/shared/nested_sidebar_menu'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; +/* tslint:disable:no-var-requires */ +const IntroMarkdown = require('md/docs/0xjs/introduction'); +const InstallationMarkdown = require('md/docs/0xjs/installation'); +const AsyncMarkdown = require('md/docs/0xjs/async'); +const ErrorsMarkdown = require('md/docs/0xjs/errors'); +const versioningMarkdown = require('md/docs/0xjs/versioning'); +/* tslint:enable:no-var-requires */ + +const SCROLL_TO_TIMEOUT = 500; +const DOC_JSON_ROOT = constants.S3_0XJS_DOCUMENTATION_JSON_ROOT; + +const sectionNameToMarkdown = { + [ZeroExJsDocSections.introduction]: IntroMarkdown, + [ZeroExJsDocSections.installation]: InstallationMarkdown, + [ZeroExJsDocSections.async]: AsyncMarkdown, + [ZeroExJsDocSections.errors]: ErrorsMarkdown, + [ZeroExJsDocSections.versioning]: versioningMarkdown, +}; + +export interface ZeroExJSDocumentationPassedProps { + source: string; + location: Location; +} + +export interface ZeroExJSDocumentationAllProps { + source: string; + location: Location; + dispatcher: Dispatcher; + docsVersion: string; + availableDocVersions: string[]; +} + +interface ZeroExJSDocumentationState { + docAgnosticFormat?: DocAgnosticFormat; +} + +const styles: Styles = { + mainContainers: { + position: 'absolute', + top: 60, + left: 0, + bottom: 0, + right: 0, + overflowZ: 'hidden', + overflowY: 'scroll', + minHeight: 'calc(100vh - 60px)', + WebkitOverflowScrolling: 'touch', + }, + menuContainer: { + borderColor: colors.grey300, + maxWidth: 330, + marginLeft: 20, + }, +}; + +export class ZeroExJSDocumentation extends React.Component<ZeroExJSDocumentationAllProps, ZeroExJSDocumentationState> { + constructor(props: ZeroExJSDocumentationAllProps) { + super(props); + this.state = { + docAgnosticFormat: undefined, + }; + } + public componentWillMount() { + const pathName = this.props.location.pathname; + const lastSegment = pathName.substr(pathName.lastIndexOf('/') + 1); + const versions = findVersions(lastSegment); + const preferredVersionIfExists = versions.length > 0 ? versions[0] : undefined; + this.fetchJSONDocsFireAndForgetAsync(preferredVersionIfExists); + } + public render() { + const menuSubsectionsBySection = _.isUndefined(this.state.docAgnosticFormat) + ? {} + : typeDocUtils.getMenuSubsectionsBySection(this.state.docAgnosticFormat); + return ( + <div> + <DocumentTitle title="0x.js Documentation"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + docsVersion={this.props.docsVersion} + availableDocVersions={this.props.availableDocVersions} + menuSubsectionsBySection={menuSubsectionsBySection} + shouldFullWidth={true} + doc={Docs.ZeroExJs} + /> + {_.isUndefined(this.state.docAgnosticFormat) ? + <div + className="col col-12" + style={styles.mainContainers} + > + <div + className="relative sm-px2 sm-pt2 sm-m1" + style={{height: 122, top: '50%', transform: 'translateY(-50%)'}} + > + <div className="center pb2"> + <CircularProgress size={40} thickness={5} /> + </div> + <div className="center pt2" style={{paddingBottom: 11}}>Loading documentation...</div> + </div> + </div> : + <div + className="mx-auto flex" + style={{color: colors.grey800, height: 43}} + > + <div className="relative col md-col-3 lg-col-3 lg-pl0 md-pl1 sm-hide xs-hide"> + <div + className="border-right absolute" + style={{...styles.menuContainer, ...styles.mainContainers}} + > + <NestedSidebarMenu + selectedVersion={this.props.docsVersion} + versions={this.props.availableDocVersions} + topLevelMenu={typeDocUtils.getFinal0xjsMenu(this.props.docsVersion)} + menuSubsectionsBySection={menuSubsectionsBySection} + doc={Docs.ZeroExJs} + /> + </div> + </div> + <div className="relative col lg-col-9 md-col-9 sm-col-12 col-12"> + <div + id="documentation" + style={styles.mainContainers} + className="absolute" + > + <div id="zeroExJSDocs" /> + <h1 className="md-pl2 sm-pl3"> + <a href={constants.GITHUB_0X_JS_URL} target="_blank"> + 0x.js + </a> + </h1> + {this.renderDocumentation()} + </div> + </div> + </div> + } + </div> + ); + } + private renderDocumentation(): React.ReactNode { + const typeDocSection = this.state.docAgnosticFormat[ZeroExJsDocSections.types]; + const typeDefinitionByName = _.keyBy(typeDocSection.types, 'name'); + + const subMenus = _.values(constants.menu0xjs); + const orderedSectionNames = _.flatten(subMenus); + const sections = _.map(orderedSectionNames, this.renderSection.bind(this, typeDefinitionByName)); + + return sections; + } + private renderSection(typeDefinitionByName: TypeDefinitionByName, sectionName: string): React.ReactNode { + const docSection = this.state.docAgnosticFormat[sectionName]; + + const markdownFileIfExists = sectionNameToMarkdown[sectionName]; + if (!_.isUndefined(markdownFileIfExists)) { + return ( + <MarkdownSection + key={`markdown-section-${sectionName}`} + sectionName={sectionName} + markdownContent={markdownFileIfExists} + /> + ); + } + + if (_.isUndefined(docSection)) { + return null; + } + + const typeDefs = _.map(docSection.types, customType => { + return ( + <TypeDefinition + key={`type-${customType.name}`} + customType={customType} + /> + ); + }); + const propertyDefs = _.map(docSection.properties, this.renderProperty.bind(this)); + const methodDefs = _.map(docSection.methods, method => { + const isConstructor = false; + return this.renderMethodBlocks(method, sectionName, isConstructor, typeDefinitionByName); + }); + return ( + <div + key={`section-${sectionName}`} + className="py2 pr3 md-pl2 sm-pl3" + > + <SectionHeader sectionName={sectionName} /> + <Comment + comment={docSection.comment} + /> + {sectionName === ZeroExJsDocSections.zeroEx && docSection.constructors.length > 0 && + <div> + <h2 className="thin">Constructor</h2> + {this.renderZeroExConstructors(docSection.constructors, typeDefinitionByName)} + </div> + } + {docSection.properties.length > 0 && + <div> + <h2 className="thin">Properties</h2> + <div>{propertyDefs}</div> + </div> + } + {docSection.methods.length > 0 && + <div> + <h2 className="thin">Methods</h2> + <div>{methodDefs}</div> + </div> + } + {typeDefs.length > 0 && + <div> + <div>{typeDefs}</div> + </div> + } + </div> + ); + } + private renderZeroExConstructors(constructors: TypescriptMethod[], + typeDefinitionByName: TypeDefinitionByName): React.ReactNode { + const constructorDefs = _.map(constructors, constructor => { + return this.renderMethodBlocks( + constructor, ZeroExJsDocSections.zeroEx, constructor.isConstructor, typeDefinitionByName, + ); + }); + return ( + <div> + {constructorDefs} + </div> + ); + } + private renderProperty(property: Property): React.ReactNode { + return ( + <div + key={`property-${property.name}-${property.type.name}`} + className="pb3" + > + <code className="hljs"> + {property.name}: <Type type={property.type} /> + </code> + <SourceLink + version={this.props.docsVersion} + source={property.source} + /> + {property.comment && + <Comment + comment={property.comment} + className="py2" + /> + } + </div> + ); + } + private renderMethodBlocks(method: TypescriptMethod, sectionName: string, isConstructor: boolean, + typeDefinitionByName: TypeDefinitionByName): React.ReactNode { + return ( + <MethodBlock + key={`method-${method.name}-${!_.isUndefined(method.source) ? method.source.line : ''}`} + method={method} + typeDefinitionByName={typeDefinitionByName} + libraryVersion={this.props.docsVersion} + /> + ); + } + private scrollToHash(): void { + const hashWithPrefix = this.props.location.hash; + let hash = hashWithPrefix.slice(1); + if (_.isEmpty(hash)) { + hash = 'zeroExJSDocs'; // scroll to the top + } + + scroller.scrollTo(hash, {duration: 0, offset: 0, containerId: 'documentation'}); + } + private async fetchJSONDocsFireAndForgetAsync(preferredVersionIfExists?: string): Promise<void> { + const versionToFileName = await docUtils.getVersionToFileNameAsync(DOC_JSON_ROOT); + const versions = _.keys(versionToFileName); + this.props.dispatcher.updateAvailableDocVersions(versions); + const sortedVersions = semverSort.desc(versions); + const latestVersion = sortedVersions[0]; + + let versionToFetch = latestVersion; + if (!_.isUndefined(preferredVersionIfExists)) { + const preferredVersionFileNameIfExists = versionToFileName[preferredVersionIfExists]; + if (!_.isUndefined(preferredVersionFileNameIfExists)) { + versionToFetch = preferredVersionIfExists; + } + } + this.props.dispatcher.updateCurrentDocsVersion(versionToFetch); + + const versionFileNameToFetch = versionToFileName[versionToFetch]; + const versionDocObj = await docUtils.getJSONDocFileAsync(versionFileNameToFetch, DOC_JSON_ROOT); + const docAgnosticFormat = typeDocUtils.convertToDocAgnosticFormat((versionDocObj as TypeDocNode)); + + this.setState({ + docAgnosticFormat, + }, () => { + this.scrollToHash(); + }); + } +} diff --git a/packages/website/ts/pages/faq/faq.tsx b/packages/website/ts/pages/faq/faq.tsx new file mode 100644 index 000000000..3c65d1042 --- /dev/null +++ b/packages/website/ts/pages/faq/faq.tsx @@ -0,0 +1,497 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as DocumentTitle from 'react-document-title'; +import RaisedButton from 'material-ui/RaisedButton'; +import {colors} from 'material-ui/styles'; +import {Styles, FAQSection, FAQQuestion, WebsitePaths} from 'ts/types'; +import {Link} from 'react-router-dom'; +import {Footer} from 'ts/components/footer'; +import {TopBar} from 'ts/components/top_bar'; +import {Question} from 'ts/pages/faq/question'; +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; + +export interface FAQProps { + source: string; + location: Location; +} + +interface FAQState {} + +const styles: Styles = { + thin: { + fontWeight: 100, + }, +}; + +const sections: FAQSection[] = [ + { + name: '0x Protocol', + questions: [ + { + prompt: 'What is 0x?', + answer: ( + <div> + At its core, 0x is an open and non-rent seeking protocol that facilitates trustless, + low friction exchange of Ethereum-based assets. Developers can use 0x as a platform + to build exchange applications on top of{' '} + (<a href={`${configs.BASE_URL}${WebsitePaths.ZeroExJs}#introduction`} target="blank">0x.js</a> + {' '}is a Javascript library for interacting with the 0x protocol). For end users, 0x will be + the infrastructure of a wide variety of user-facing applications i.e.{' '} + <a href={`${configs.BASE_URL}${WebsitePaths.Portal}`} target="blank">0x Portal</a>, + a decentralized application that facilitates trustless trading of Ethereum-based tokens + between known counterparties. + </div> + ), + }, + { + prompt: 'What problem does 0x solve?', + answer: ( + <div> + In the two years since the Ethereum blockchain’s genesis block, numerous decentralized + applications (dApps) have created Ethereum smart contracts for peer-to-peer exchange. + Rapid iteration and a lack of best practices have left the blockchain scattered with + proprietary and application-specific implementations. As a result, end users are + exposed to numerous smart contracts of varying quality and security, with unique + configuration processes and learning curves, all of which implement the same + functionality. This approach imposes unnecessary costs on the network by fragmenting + end users according to the particular dApp each user happens to be using, eliminating + valuable network effects around liquidity. 0x is the solution to this problem by + acting as modular, unopinionated building blocks that may be assembled and reconfigured. + </div> + ), + }, + { + prompt: 'How is 0x different from a centralized exchange like Poloniex or ShapeShift?', + answer: ( + <div> + <ul> + <li> + 0x is a protocol for exchange, not a user-facing exchange application. + </li> + <li> + 0x is decentralized and trustless; there is no central party which can be + hacked, run away with customer funds or be subjected to government regulations. + Hacks of Mt. Gox, Shapeshift and Bitfinex have demonstrated that these types of + systemic risks are palpable. + </li> + <li> + Rather than a proprietary system that exists to extract rent for its owners, + 0x is public infrastructure that is funded by a globally distributed community + of stakeholders. While the protocol is free to use, it enables for-profit + user-facing exchange applications to be built on top of the protocol. + </li> + </ul> + </div> + ), + }, + { + prompt: 'If 0x protocol is free to use, where do transaction fees come in?', + answer: ( + <div> + 0x protocol uses off-chain order books to massively reduce friction costs for + market makers and ensure that the blockchain is only used for trade settlement. + Hosting and maintaining an off-chain order book is a service; to incent “Relayers” + to provide this service they must be able to charge transaction fees on trading + activity. Relayers are free to set their transaction fees to any value they desire. + We expect Relayers to be highly competitive and transaction fees to approach an + efficient economic equilibrium over time. + </div> + ), + }, + { + prompt: 'What are the differences between 0x protocol and state channels?', + answer: ( + <div> + <div> + Participants in a state channel pass cryptographically signed messages back and + forth, accumulating intermediate state changes without publishing them to the + canonical chain until the channel is closed. State channels are ideal for “bar tab” + applications where numerous intermediate state changes may be accumulated off-chain + before being settled by a final on-chain transaction (i.e. day trading, poker, + turn-based games). + </div> + <ul> + <li> + While state channels drastically reduce the number of on-chain transactions + for specific use cases, numerous on-chain transactions and a security deposit + are required to open and safely close a state channel making them less efficient + than 0x for executing one-time trades. + </li> + <li> + State channels are isolated from the Ethereum blockchain meaning that + they cannot interact with smart contracts. 0x is designed to be integrated + directly into smart contracts so trades can be executed programmatically + in a single line of Solidity code. + </li> + </ul> + </div> + ), + }, + { + prompt: 'What types of digital assets are supported by 0x?', + answer: ( + <div> + 0x supports all Ethereum-based assets that adhere to the ERC20 token standard. + There are many ERC20 tokens, worth a combined $2.2B, and more tokens are created + each month. We believe that, by 2020, thousands of assets will be tokenized and + moved onto the Ethereum blockchain including traditional securities such as equities, + bonds and derivatives, fiat currencies and scarce digital goods such as video game + items. In the future, cross-blockchain solutions such as{' '} + <a href="https://cosmos.network/" target="_blank">Cosmos</a> and{' '} + <a href="http://polkadot.io/" target="_blank">Polkadot</a> will allow cryptocurrencies + to freely move between blockchains and, naturally, currencies such as Bitcoin will + end up being represented as ERC20 tokens on the Ethereum blockchain. + </div> + ), + }, + { + prompt: '0x is open source: what prevents someone from forking the protocol?', + answer: ( + <div> + Ethereum and Bitcoin are both open source protocols. Each protocol has been forked, + but the resulting clone networks have seen little adoption (as measured by transaction + count or market cap). This is because users have little to no incentive to switch + over to a clone network if the original has initial network effects and a talented + developer team behind it. + An exception is in the case that a protocol includes a controversial feature such as + a method of rent extraction or a monetary policy that favors one group of users over + another (Zcash developer subsidy - for better or worse - resulted in Zclassic). + Perceived inequality can provide a strong enough incentive that users will fork the + original protocol’s codebase and spin up a new network that eliminates the controversial + feature. In the case of 0x, there is no rent extraction and no users are given + special permissions. + + 0x protocol is upgradable. Cutting-edge technical capabilities can be integrated + into 0x via decentralized governance (see section below), eliminating incentives + to fork off of the original protocol and sacrifice the network effects surrounding + liquidity that result from the shared protocol and settlement layer. + </div> + ), + }, + ], + }, + { + name: '0x Token (ZRX)', + questions: [ + { + prompt: 'Explain how the 0x protocol token (zrx) works.', + answer: ( + <div> + <div> + 0x protocol token (ZRX) is utilized in two ways: 1) to solve the{' '} + <a href="https://en.wikipedia.org/wiki/Coordination_game" target="_blank"> + coordination problem + </a> and drive network effects around liquidity, creating a feedback loop + where early adopters of the protocol benefit from wider adoption and 2) to + be used for decentralized governance over 0x protocol's update mechanism. + In more detail: + </div> + <ul> + <li> + ZRX tokens are used by Makers and Takers (market participants that generate + and consume orders, respectively) to pay transaction fees to Relayers + (entities that host and maintain public order books). + </li> + <li> + ZRX tokens are used for decentralized governance over 0x protocol’s update + mechanism which allows its underlying smart contracts to be replaced and + improved over time. An update mechanism is needed because 0x is built upon + Ethereum’s rapidly evolving technology stack, decentralized governance is + needed because 0x protocol’s smart contracts will have access to user funds + and numerous dApps will need to plug into 0x smart contracts. Decentralized + governance ensures that this update process is secure and minimizes disruption + to the network. + </li> + </ul> + </div> + ), + }, + { + prompt: 'Why must transaction fees be denominated in 0x token (ZRX) rather than ETH?', + answer: ( + <div> + 0x protocol’s decentralized update mechanism is analogous to proof-of-stake. + To maximize the alignment of stakeholder and end user incentives, the staked + asset must provide utility within the protocol. + </div> + ), + }, + { + prompt: 'How will decentralized governance work?', + answer: ( + <div> + Decentralized governance is an ongoing focus of research; it will involve token + voting with ZRX. Ultimately the solution will maximize security while also maximizing + the protocol’s ability to absorb new innovations. Until the governance structure is + formalized and encoded within 0x DAO, a multi-sig will be used as a placeholder. + </div> + ), + }, + ], + }, + { + name: 'ZRX Token Launch and Fund Use', + questions: [ + { + prompt: 'What is the total supply of ZRX tokens?', + answer: ( + <div> + 1,000,000,000 ZRX. Fixed supply. + </div> + ), + }, + { + prompt: 'When is the Token Launch? will there be a pre-sale?', + answer: ( + <div> + The token launch will be on August 15th, 2017. There will not be a pre-sale. + </div> + ), + }, + { + prompt: 'What will the token launch proceeds be used for?', + answer: ( + <div> + 100% of the proceeds raised in the token launch will be used to fund the development + of free and open source software, tools and infrastructure that support the protocol + and surrounding ecosystem. Check out our{' '} + <a + href="https://docs.google.com/document/d/1_RVa-_bkU92fWRsC8eNy4vYjcTt-WC8GtqyyjbTd-oY" + target="_blank" + > + development roadmap + </a>. + </div> + ), + }, + { + prompt: 'What will be the initial distribution of ZRX tokens?', + answer: ( + <div> + <div className="center" style={{width: '100%'}}> + <img + style={{width: 350}} + src="/images/zrx_pie_chart.png" + /> + </div> + <div className="py1"> + <div className="bold pb1"> + Token Launch (50%) + </div> + <div> + ZRX is inherently a governance token that plays a critical role in the + process of upgrading 0x protocol. We are fully committed to formulating + a functional and theoretically sound governance model and we plan to dedicate + significant resources to R&D. + </div> + </div> + <div className="py1"> + <div className="bold pb1"> + Retained by 0x (15%) + </div> + <div> + The 0x core development team will be able to sustain itself for approximately + five years using funds raised through the token launch. If 0x protocol + proves to be as foundational a technology as we believe it to be, the + retained ZRX tokens will allow the 0x core development team to sustain + operations beyond the first 5 years. + </div> + </div> + <div className="py1"> + <div className="bold pb1"> + Developer Fund (15%) + </div> + <div> + The Developer Fund will be used to make targeted capital injections + into high potential projects and teams that are attempting to grow + the 0x ecosystem, strategic partnerships, hackathon prizes and community + development activities. + </div> + </div> + <div className="py1"> + <div className="bold pb1"> + Founding Team (10%) + </div> + <div> + The founding team’s allocation of ZRX will vest over a traditional 4 + year vesting schedule with a one year cliff. We believe this should + be standard practice for any team that is committed to making their + project a long term success. + </div> + </div> + <div className="py1"> + <div className="bold pb1"> + Early Backers & Advisors (10%) + </div> + <div> + Our backers and advisors have provided capital, resources and guidance + that have allowed us to fill out our team, setup a robust legal entity + and build a fully functional product before launching a token. As a result, + we have a proven track record and can offer a token that holds genuine utility. + </div> + </div> + </div> + ), + }, + { + prompt: 'Can I mine ZRX tokens?', + answer: ( + <div> + No, the total supply of ZRX tokens is fixed and there is no continuous issuance + model. Users that facilitate trading over 0x protocol by operating a Relayer + earn transaction fees denominated in ZRX; as more trading activity is generated, + more transaction fees are earned. + </div> + ), + }, + { + prompt: 'Will there be a lockup period for ZRX tokens sold in the token launch?', + answer: ( + <div> + No, ZRX tokens sold in the token launch will immediately be liquid. + </div> + ), + }, + { + prompt: 'Will there be a lockup period for tokens allocated to the founding team?', + answer: ( + <div> + Yes. ZRX tokens allocated to founders, advisors and staff members will be released + over a 4 year vesting schedule with a 25% cliff upon completion of the initial + token launch and 25% released each subsequent year in monthly installments. Staff + members hired after the token launch will have a 4 year vesting schedule with a + one year cliff. + </div> + ), + }, + { + prompt: 'Which cryptocurrencies will be accepted in the token launch?', + answer: ( + <div>ETH.</div> + ), + }, + { + prompt: 'When will 0x be live?', + answer: ( + <div> + An alpha version of 0x has been live on our private test network since January + 2017. Version 1.0 of 0x protocol will be deployed to the canonical Ethereum + blockchain after a round of security audits and prior to the public token launch. + 0x will be using the 0x protocol during our token launch. + </div> + ), + }, + { + prompt: 'Where can I find a development roadmap?', + answer: ( + <div> + Check it out{' '} + <a + href="https://drive.google.com/open?id=14IP1N8mt3YdsAoqYTyruMnZswpklUs3THyS1VXx71fo" + target="_blank" + > + here + </a>. + </div> + ), + }, + ], + }, + { + name: 'Team', + questions: [ + { + prompt: 'Where is 0x based?', + answer: ( + <div> + 0x was founded in SF and is driven by a diverse group of contributors. + </div> + ), + }, + { + prompt: 'How can I get involved?', + answer: ( + <div> + Join our <a href={constants.ZEROEX_CHAT_URL} target="_blank">Rocket.chat</a>! + As an open source project, 0x will rely on a worldwide community of passionate + developers to contribute proposals, ideas and code. + </div> + ), + }, + { + prompt: 'Why the name 0x?', + answer: ( + <div> + 0x is the prefix for hexadecimal numeric constants including Ethereum addresses. + In a more abstract context, as the first open protocol for exchange 0x represents + the beginning of the end for the exchange industry’s rent seeking oligopoly: + zero exchange. + </div> + ), + }, + { + prompt: 'How do you pronounce 0x?', + answer: ( + <div> + We pronounce 0x as “zero-ex,” but you are free to pronounce it however you please. + </div> + ), + }, + ], + }, +]; + +export class FAQ extends React.Component<FAQProps, FAQState> { + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + return ( + <div> + <DocumentTitle title="0x FAQ"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + /> + <div + id="faq" + className="mx-auto max-width-4 pt4" + style={{color: colors.grey800}} + > + <h1 className="center" style={{...styles.thin}}>0x FAQ</h1> + <div className="sm-px2 md-px2 lg-px0 pb4"> + {this.renderSections()} + </div> + </div> + <Footer location={this.props.location} /> + </div> + ); + } + private renderSections() { + const renderedSections = _.map(sections, (section: FAQSection, i: number) => { + const isFirstSection = i === 0; + return ( + <div key={section.name}> + <h3>{section.name}</h3> + {this.renderQuestions(section.questions, isFirstSection)} + </div> + ); + }); + return renderedSections; + } + private renderQuestions(questions: FAQQuestion[], isFirstSection: boolean) { + const renderedQuestions = _.map(questions, (question: FAQQuestion, i: number) => { + const isFirstQuestion = i === 0; + return ( + <Question + key={question.prompt} + prompt={question.prompt} + answer={question.answer} + shouldDisplayExpanded={isFirstSection && isFirstQuestion} + /> + ); + }); + return renderedQuestions; + } +} diff --git a/packages/website/ts/pages/faq/question.tsx b/packages/website/ts/pages/faq/question.tsx new file mode 100644 index 000000000..4ed198b91 --- /dev/null +++ b/packages/website/ts/pages/faq/question.tsx @@ -0,0 +1,52 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Card, CardHeader, CardText} from 'material-ui/Card'; + +export interface QuestionProps { + prompt: string; + answer: React.ReactNode; + shouldDisplayExpanded: boolean; +} + +interface QuestionState { + isExpanded: boolean; +} + +export class Question extends React.Component<QuestionProps, QuestionState> { + constructor(props: QuestionProps) { + super(props); + this.state = { + isExpanded: props.shouldDisplayExpanded, + }; + } + public render() { + return ( + <div + className="py1" + > + <Card + initiallyExpanded={this.props.shouldDisplayExpanded} + onExpandChange={this.onExchangeChange.bind(this)} + > + <CardHeader + title={this.props.prompt} + style={{borderBottom: this.state.isExpanded ? '1px solid rgba(0, 0, 0, 0.19)' : 'none'}} + titleStyle={{color: 'rgb(66, 66, 66)'}} + actAsExpander={true} + showExpandableButton={true} + /> + <CardText expandable={true}> + <div style={{lineHeight: 1.4}}> + {this.props.answer} + </div> + </CardText> + </Card> + </div> + ); + } + private onExchangeChange() { + this.setState({ + isExpanded: !this.state.isExpanded, + }); + } +} diff --git a/packages/website/ts/pages/landing/landing.tsx b/packages/website/ts/pages/landing/landing.tsx new file mode 100644 index 000000000..32ea86736 --- /dev/null +++ b/packages/website/ts/pages/landing/landing.tsx @@ -0,0 +1,843 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import DocumentTitle = require('react-document-title'); +import {Link} from 'react-router-dom'; +import RaisedButton from 'material-ui/RaisedButton'; +import {colors} from 'material-ui/styles'; +import {configs} from 'ts/utils/configs'; +import {constants} from 'ts/utils/constants'; +import {Styles, WebsitePaths, ScreenWidths} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {TopBar} from 'ts/components/top_bar'; +import {Footer} from 'ts/components/footer'; + +interface BoxContent { + title: string; + description: string; + imageUrl: string; + classNames: string; +} +interface AssetType { + title: string; + imageUrl: string; + style?: React.CSSProperties; +} +interface UseCase { + imageUrl: string; + type: string; + description: string; + classNames: string; + style?: React.CSSProperties; + projectIconUrls: string[]; +} +interface Project { + logoFileName: string; + projectUrl: string; +} + +const THROTTLE_TIMEOUT = 100; +const CUSTOM_HERO_BACKGROUND_COLOR = '#404040'; +const CUSTOM_PROJECTS_BACKGROUND_COLOR = '#343333'; +const CUSTOM_WHITE_BACKGROUND = 'rgb(245, 245, 245)'; +const CUSTOM_WHITE_TEXT = '#E4E4E4'; +const CUSTOM_GRAY_TEXT = '#919191'; + +const boxContents: BoxContent[] = [ + { + title: 'Trustless exchange', + description: 'Built on Ethereum\'s distributed network with no centralized \ + point of failure and no down time, each trade is settled atomically \ + and without counterparty risk.', + imageUrl: '/images/landing/distributed_network.png', + classNames: '', + }, + { + title: 'Shared liquidity', + description: 'By sharing a standard API, relayers can easily aggregate liquidity pools, \ + creating network effects around liquidity that compound as more relayers come online.', + imageUrl: '/images/landing/liquidity.png', + classNames: 'mx-auto', + }, + { + title: 'Open source', + description: '0x is open source, permissionless and free to use. Trade directly with a known \ + counterparty for free or pay a relayer some ZRX tokens to access their liquidity \ + pool.', + imageUrl: '/images/landing/open_source.png', + classNames: 'right', + }, +]; + +const projects: Project[] = [ + { + logoFileName: 'ethfinex-top.png', + projectUrl: constants.ETHFINEX_URL, + }, + { + logoFileName: 'radar_relay_top.png', + projectUrl: constants.RADAR_RELAY_URL, + }, + { + logoFileName: 'paradex_top.png', + projectUrl: constants.PARADEX_URL, + }, + { + logoFileName: 'the_ocean.png', + projectUrl: constants.OCEAN_URL, + }, + { + logoFileName: 'dydx.png', + projectUrl: constants.DYDX_URL, + }, + { + logoFileName: 'melonport.png', + projectUrl: constants.MELONPORT_URL, + }, + { + logoFileName: 'maker.png', + projectUrl: constants.MAKER_URL, + }, + { + logoFileName: 'dharma.png', + projectUrl: constants.DHARMA_URL, + }, + { + logoFileName: 'lendroid.png', + projectUrl: constants.LENDROID_URL, + }, + { + logoFileName: 'district0x.png', + projectUrl: constants.DISTRICT_0X_URL, + }, + { + logoFileName: 'aragon.png', + projectUrl: constants.ARAGON_URL, + }, + { + logoFileName: 'blocknet.png', + projectUrl: constants.BLOCKNET_URL, + }, + { + logoFileName: 'status.png', + projectUrl: constants.STATUS_URL, + }, + { + logoFileName: 'augur.png', + projectUrl: constants.AUGUR_URL, + }, + { + logoFileName: 'anx.png', + projectUrl: constants.OPEN_ANX_URL, + }, + { + logoFileName: 'auctus.png', + projectUrl: constants.AUCTUS_URL, + }, +]; + +export interface LandingProps { + location: Location; +} + +interface LandingState { + screenWidth: ScreenWidths; +} + +export class Landing extends React.Component<LandingProps, LandingState> { + private throttledScreenWidthUpdate: () => void; + constructor(props: LandingProps) { + super(props); + this.state = { + screenWidth: utils.getScreenWidth(), + }; + this.throttledScreenWidthUpdate = _.throttle(this.updateScreenWidth.bind(this), THROTTLE_TIMEOUT); + } + public componentDidMount() { + window.addEventListener('resize', this.throttledScreenWidthUpdate); + window.scrollTo(0, 0); + } + public componentWillUnmount() { + window.removeEventListener('resize', this.throttledScreenWidthUpdate); + } + public render() { + return ( + <div id="landing" className="clearfix" style={{color: colors.grey800}}> + <DocumentTitle title="0x Protocol"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + isNightVersion={true} + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR, position: 'relative'}} + /> + {this.renderHero()} + {this.renderProjects()} + {this.renderTokenizationSection()} + {this.renderProtocolSection()} + {this.renderInfoBoxes()} + {this.renderBuildingBlocksSection()} + {this.renderUseCases()} + {this.renderCallToAction()} + <Footer location={this.props.location} /> + </div> + ); + } + private renderHero() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const buttonLabelStyle: React.CSSProperties = { + textTransform: 'none', + fontSize: isSmallScreen ? 12 : 14, + fontWeight: 400, + }; + const lightButtonStyle: React.CSSProperties = { + borderRadius: 6, + border: '1px solid #D8D8D8', + lineHeight: '33px', + height: 38, + }; + const left = 'col lg-col-7 md-col-7 col-12 lg-pt4 md-pt4 sm-pt0 mt1 lg-pl4 md-pl4 sm-pl0 sm-px3 sm-center'; + return ( + <div + className="clearfix py4" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div + className="mx-auto max-width-4 clearfix" + > + <div className="lg-pt4 md-pt4 sm-pt2 lg-pb4 md-pb4 lg-my4 md-my4 sm-mt2 sm-mb4 clearfix"> + <div className="col lg-col-5 md-col-5 col-12 sm-center"> + <img + src="/images/landing/hero_chip_image.png" + height={isSmallScreen ? 300 : 395} + /> + </div> + <div + className={left} + style={{color: 'white'}} + > + <div style={{paddingLeft: isSmallScreen ? 0 : 12}}> + <div + className="sm-pb2" + style={{fontFamily: 'Roboto Mono', fontSize: isSmallScreen ? 26 : 34}} + > + Powering decentralized exchange + </div> + <div + className="pt2 h5 sm-mx-auto" + style={{maxWidth: 446, fontFamily: 'Roboto Mono', lineHeight: 1.7, fontWeight: 300}} + > + 0x is an open, permissionless protocol allowing for ERC20 tokens to + be traded on the Ethereum blockchain. + </div> + <div className="pt3 clearfix sm-mx-auto" style={{maxWidth: 342}}> + <div className="lg-pr2 md-pr2 col col-6 sm-center"> + <Link to={WebsitePaths.ZeroExJs} className="text-decoration-none"> + <RaisedButton + style={{borderRadius: 6, minWidth: 157.36}} + buttonStyle={{borderRadius: 6}} + labelStyle={buttonLabelStyle} + label="Build on 0x" + onClick={_.noop} + /> + </Link> + </div> + <div className="col col-6 sm-center"> + <a + href={constants.ZEROEX_CHAT_URL} + target="_blank" + className="text-decoration-none" + > + <RaisedButton + style={{borderRadius: 6, minWidth: 150}} + buttonStyle={lightButtonStyle} + labelColor="white" + backgroundColor={CUSTOM_HERO_BACKGROUND_COLOR} + labelStyle={buttonLabelStyle} + label="Join the community" + onClick={_.noop} + /> + </a> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + ); + } + private renderProjects() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const isMediumScreen = this.state.screenWidth === ScreenWidths.MD; + const projectList = _.map(projects, (project: Project, i: number) => { + const colWidth = isSmallScreen ? 3 : isMediumScreen ? 4 : 2 - (i % 2); + return ( + <div + key={`project-${project.logoFileName}`} + className={`col col-${colWidth} center`} + > + <div> + <a + href={project.projectUrl} + target="_blank" + className="text-decoration-none" + > + <img + src={`/images/landing/project_logos/${project.logoFileName}`} + height={isSmallScreen ? 60 : 92} + /> + </a> + </div> + </div> + ); + }); + const titleStyle: React.CSSProperties = { + fontFamily: 'Roboto Mono', + color: '#A4A4A4', + textTransform: 'uppercase', + fontWeight: 300, + letterSpacing: 3, + }; + return ( + <div + className="clearfix py4" + style={{backgroundColor: CUSTOM_PROJECTS_BACKGROUND_COLOR}} + > + <div className="mx-auto max-width-4 clearfix sm-px3"> + <div + className="h4 pb3 md-pl3 sm-pl2" + style={titleStyle} + > + Projects building on 0x + </div> + <div className="clearfix"> + {projectList} + </div> + <div + className="pt3 mx-auto center" + style={{color: CUSTOM_GRAY_TEXT, fontFamily: 'Roboto Mono', maxWidth: 300, fontSize: 14}} + > + view the{' '} + <Link + to={`${WebsitePaths.Wiki}#List-of-Projects-Using-0x-Protocol`} + className="text-decoration-none underline" + style={{color: CUSTOM_GRAY_TEXT}} + > + full list + </Link> + </div> + </div> + </div> + ); + } + private renderTokenizationSection() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + return ( + <div + className="clearfix lg-py4 md-py4 sm-pb4 sm-pt2" + style={{backgroundColor: CUSTOM_WHITE_BACKGROUND}} + > + <div className="mx-auto max-width-4 py4 clearfix"> + {isSmallScreen && + this.renderTokenCloud() + } + <div className="col lg-col-6 md-col-6 col-12"> + <div className="mx-auto" style={{maxWidth: 385, paddingTop: 7}}> + <div + className="lg-h1 md-h1 sm-h2 sm-center sm-pt3" + style={{fontFamily: 'Roboto Mono'}} + > + The world's value is becoming tokenized + </div> + <div + className="pb2 lg-pt2 md-pt2 sm-pt3 sm-px3 h5 sm-center" + style={{fontFamily: 'Roboto Mono', lineHeight: 1.7}} + > + {isSmallScreen ? + <span> + The Ethereum blockchain is an open, borderless financial system that represents + a wide variety of assets as cryptographic tokens. In the future, most digital + assets and goods will be tokenized. + </span> : + <div> + <div> + The Ethereum blockchain is an open, borderless + financial system that represents + </div> + <div> + a wide variety of assets as cryptographic tokens. + In the future, most digital assets and goods will be tokenized. + </div> + </div> + } + </div> + <div className="flex pt1 sm-px3"> + {this.renderAssetTypes()} + </div> + </div> + </div> + {!isSmallScreen && + this.renderTokenCloud() + } + </div> + </div> + ); + } + private renderProtocolSection() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + return ( + <div + className="clearfix lg-py4 md-py4 sm-pt4" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div className="mx-auto max-width-4 lg-py4 md-py4 sm-pt4 clearfix"> + <div className="col lg-col-6 md-col-6 col-12 sm-center"> + <img + src="/images/landing/relayer_diagram.png" + height={isSmallScreen ? 326 : 426} + /> + </div> + <div + className="col lg-col-6 md-col-6 col-12 lg-pr3 md-pr3 sm-mx-auto" + style={{color: CUSTOM_WHITE_TEXT, paddingTop: 8, maxWidth: isSmallScreen ? 'none' : 445}} + > + <div + className="lg-h1 md-h1 sm-h2 pb1 sm-pt3 sm-center" + style={{fontFamily: 'Roboto Mono'}} + > + <div> + Off-chain order relay + </div> + <div> + On-chain settlement + </div> + </div> + <div + className="pb2 pt2 h5 sm-center sm-px3 sm-mx-auto" + style={{fontFamily: 'Roboto Mono', lineHeight: 1.7, fontWeight: 300, maxWidth: 445}} + > + In 0x protocol, orders are transported off-chain, massively reducing gas + costs and eliminating blockchain bloat. Relayers help broadcast orders and + collect a fee each time they facilitate a trade. Anyone can build a relayer. + </div> + <div + className="pt3 sm-mx-auto sm-px3" + style={{color: CUSTOM_GRAY_TEXT, maxWidth: isSmallScreen ? 412 : 'none'}} + > + <div className="flex" style={{fontSize: 18}}> + <div + className="lg-h4 md-h4 sm-h5" + style={{letterSpacing: isSmallScreen ? 1 : 3, fontFamily: 'Roboto Mono'}} + > + RELAYERS BUILDING ON 0X + </div> + <div className="h5" style={{marginLeft: isSmallScreen ? 26 : 49}}> + <Link + to={`${WebsitePaths.Wiki}#List-of-Projects-Using-0x-Protocol`} + className="text-decoration-none underline" + style={{color: CUSTOM_GRAY_TEXT, fontFamily: 'Roboto Mono'}} + > + view all + </Link> + </div> + </div> + <div className="lg-flex md-flex sm-clearfix pt3" style={{opacity: 0.4}}> + <div className="col col-4 sm-center"> + <img + src="/images/landing/ethfinex.png" + style={{height: isSmallScreen ? 85 : 107}} + /> + </div> + <div + className="col col-4 center" + > + <img + src="/images/landing/radar_relay.png" + style={{height: isSmallScreen ? 85 : 107}} + /> + </div> + <div className="col col-4 sm-center" style={{textAlign: 'right'}}> + <img + src="/images/landing/paradex.png" + style={{height: isSmallScreen ? 85 : 107}} + /> + </div> + </div> + </div> + </div> + </div> + </div> + ); + } + private renderBuildingBlocksSection() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const underlineStyle: React.CSSProperties = { + height: isSmallScreen ? 18 : 23, + lineHeight: 'none', + borderBottom: '2px solid #979797', + }; + const descriptionStyle: React.CSSProperties = { + fontFamily: 'Roboto Mono', + lineHeight: isSmallScreen ? 1.5 : 2, + fontWeight: 300, + fontSize: 15, + maxWidth: isSmallScreen ? 375 : 'none', + }; + const callToActionStyle: React.CSSProperties = { + fontFamily: 'Roboto Mono', + fontSize: 15, + fontWeight: 300, + maxWidth: isSmallScreen ? 375 : 441, + }; + return ( + <div + className="clearfix lg-pt4 md-pt4" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div className="mx-auto max-width-4 lg-pt4 md-pt4 lg-mb4 md-mb4 sm-mb2 clearfix"> + {isSmallScreen && + this.renderBlockChipImage() + } + <div + className="col lg-col-6 md-col-6 col-12 lg-pr3 md-pr3 sm-px3" + style={{color: CUSTOM_WHITE_TEXT}} + > + <div + className="pb1 lg-pt4 md-pt4 sm-pt3 lg-h1 md-h1 sm-h2 sm-px3 sm-center" + style={{fontFamily: 'Roboto Mono'}} + > + A building block for dApps + </div> + <div + className="pb3 pt2 sm-mx-auto sm-center" + style={descriptionStyle} + > + 0x protocol is a pluggable building block for dApps that require exchange functionality. + Join the many developers that are already using 0x in their web applications and smart + contracts. + </div> + <div + className="sm-mx-auto sm-center" + style={callToActionStyle} + > + Learn how in our{' '} + <Link + to={WebsitePaths.ZeroExJs} + className="text-decoration-none underline" + style={{color: CUSTOM_WHITE_TEXT, fontFamily: 'Roboto Mono'}} + > + 0x.js + </Link> + {' '}and{' '} + <Link + to={WebsitePaths.SmartContracts} + className="text-decoration-none underline" + style={{color: CUSTOM_WHITE_TEXT, fontFamily: 'Roboto Mono'}} + > + smart contract + </Link> + {' '}docs + </div> + </div> + {!isSmallScreen && + this.renderBlockChipImage() + } + </div> + </div> + ); + } + private renderBlockChipImage() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + return ( + <div className="col lg-col-6 md-col-6 col-12 sm-center"> + <img + src="/images/landing/0x_chips.png" + height={isSmallScreen ? 240 : 368} + /> + </div> + ); + } + private renderTokenCloud() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + return ( + <div className="col lg-col-6 md-col-6 col-12 center"> + <img + src="/images/landing/tokenized_world.png" + height={isSmallScreen ? 280 : 364.5} + /> + </div> + ); + } + private renderAssetTypes() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const assetTypes: AssetType[] = [ + { + title: 'Currency', + imageUrl: '/images/landing/currency.png', + }, + { + title: 'Traditional assets', + imageUrl: '/images/landing/stocks.png', + style: {paddingLeft: isSmallScreen ? 41 : 56, paddingRight: isSmallScreen ? 41 : 56}, + }, + { + title: 'Digital goods', + imageUrl: '/images/landing/digital_goods.png', + }, + ]; + const assets = _.map(assetTypes, (assetType: AssetType) => { + const style = _.isUndefined(assetType.style) ? {} : assetType.style; + return ( + <div + key={`asset-${assetType.title}`} + className="center" + style={{opacity: 0.8, ...style}} + > + <div> + <img + src={assetType.imageUrl} + height="80" + /> + </div> + <div style={{fontFamily: 'Roboto Mono', fontSize: 13.5, fontWeight: 400, opacity: 0.75}}> + {assetType.title} + </div> + </div> + ); + }); + return assets; + } + private renderLink(label: string, path: string, color: string, style?: React.CSSProperties) { + return ( + <div + style={{borderBottom: `1px solid ${color}`, paddingBottom: 1, height: 20, lineHeight: 1.7, ...style}} + > + <Link + to={path} + className="text-decoration-none" + style={{color, fontFamily: 'Roboto Mono'}} + > + {label} + </Link> + </div> + ); + } + private renderInfoBoxes() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const boxStyle: React.CSSProperties = { + maxWidth: 252, + height: 386, + backgroundColor: '#F9F9F9', + borderRadius: 5, + padding: '10px 24px 24px', + }; + const boxes = _.map(boxContents, (boxContent: BoxContent) => { + return ( + <div + key={`box-${boxContent.title}`} + className="col lg-col-4 md-col-4 col-12 sm-pb4" + > + <div + className={`center sm-mx-auto ${!isSmallScreen && boxContent.classNames}`} + style={boxStyle} + > + <div> + <img src={boxContent.imageUrl} style={{height: 210}} /> + </div> + <div + className="h3" + style={{color: 'black', fontFamily: 'Roboto Mono'}} + > + {boxContent.title} + </div> + <div + className="pt2 pb2" + style={{fontFamily: 'Roboto Mono', fontSize: 14}} + > + {boxContent.description} + </div> + </div> + </div> + ); + + }); + return ( + <div + className="clearfix" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div + className="mx-auto py4 sm-mt2 clearfix" + style={{maxWidth: '60em'}} + > + {boxes} + </div> + </div> + ); + } + private renderUseCases() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const isMediumScreen = this.state.screenWidth === ScreenWidths.MD; + + const useCases: UseCase[] = [ + { + imageUrl: '/images/landing/governance_icon.png', + type: 'Decentralized governance', + description: 'Decentralized organizations use tokens to represent ownership and \ + guide their governance logic. 0x allows decentralized organizations \ + to seamlessly and safely trade ownership for startup capital.', + projectIconUrls: ['/images/landing/aragon.png'], + classNames: 'lg-px2 md-px2', + }, + { + imageUrl: '/images/landing/prediction_market_icon.png', + type: 'Prediction markets', + description: 'Decentralized prediction market platforms generate sets of tokens that \ + represent a financial stake in the outcomes of real-world events. 0x allows \ + these tokens to be instantly tradable.', + projectIconUrls: ['/images/landing/augur.png'], + classNames: 'lg-px2 md-px2', + }, + { + imageUrl: '/images/landing/stable_tokens_icon.png', + type: 'Stable tokens', + description: 'Novel economic constructs such as stable coins require efficient, liquid \ + markets to succeed. 0x will facilitate the underlying economic mechanisms \ + that allow these tokens to remain stable.', + projectIconUrls: ['/images/landing/maker.png'], + classNames: 'lg-px2 md-px2', + }, + { + imageUrl: '/images/landing/loans_icon.png', + type: 'Decentralized loans', + description: 'Efficient lending requires liquid markets where investors can buy and re-sell loans. \ + 0x enables an ecosystem of lenders to self-organize and efficiently determine \ + market prices for all outstanding loans.', + projectIconUrls: ['/images/landing/dharma.png', '/images/landing/lendroid.png'], + classNames: 'lg-pr2 md-pr2 lg-col-6 md-col-6', + style: {width: 291, float: 'right', marginTop: !isSmallScreen ? 38 : 0}, + }, + { + imageUrl: '/images/landing/fund_management_icon.png', + type: 'Fund management', + description: 'Decentralized fund management limits fund managers to investing in pre-agreed \ + upon asset classes. Embedding 0x into fund management smart contracts enables \ + them to enforce these security constraints.', + projectIconUrls: ['/images/landing/melonport.png'], + classNames: 'lg-pl2 md-pl2 lg-col-6 md-col-6', + style: {width: 291, marginTop: !isSmallScreen ? 38 : 0}, + }, + ]; + + const cases = _.map(useCases, (useCase: UseCase) => { + const style = _.isUndefined(useCase.style) || isSmallScreen ? {} : useCase.style; + const useCaseBoxStyle = { + color: '#A2A2A2', + border: '1px solid #565656', + borderRadius: 4, + maxWidth: isSmallScreen ? 375 : 'none', + ...style, + }; + const typeStyle: React.CSSProperties = { + color: '#EBEBEB', + fontSize: 13, + textTransform: 'uppercase', + fontFamily: 'Roboto Mono', + fontWeight: 300, + }; + return ( + <div + key={`useCase-${useCase.type}`} + className={`col lg-col-4 md-col-4 col-12 sm-pt3 sm-px3 sm-pb3 ${useCase.classNames}`} + > + <div + className="relative p2 pb2 sm-mx-auto" + style={useCaseBoxStyle} + > + <div + className="absolute center" + style={{top: -35, width: 'calc(100% - 32px)'}} + > + <img src={useCase.imageUrl} style={{height: 50}} /> + </div> + <div className="pt2 center" style={typeStyle}> + {useCase.type} + </div> + <div + className="pt2" + style={{lineHeight: 1.5, fontSize: 14, overflow: 'hidden', height: 104}} + > + {useCase.description} + </div> + </div> + </div> + ); + }); + return ( + <div + className="clearfix pb4 lg-pt2 md-pt2 sm-pt4" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div + className="mx-auto pb4 pt3 mt1 sm-mt2 clearfix" + style={{maxWidth: '67em'}} + > + {cases} + </div> + </div> + ); + } + private renderCallToAction() { + const isSmallScreen = this.state.screenWidth === ScreenWidths.SM; + const buttonLabelStyle: React.CSSProperties = { + textTransform: 'none', + fontSize: 15, + fontWeight: 400, + }; + const lightButtonStyle: React.CSSProperties = { + borderRadius: 6, + border: '1px solid #a0a0a0', + lineHeight: '33px', + height: 49, + }; + const callToActionClassNames = 'col lg-col-8 md-col-8 col-12 lg-pr3 md-pr3 \ + lg-right-align md-right-align sm-center sm-px3 h4'; + return ( + <div + className="clearfix pb4" + style={{backgroundColor: CUSTOM_HERO_BACKGROUND_COLOR}} + > + <div + className="mx-auto max-width-4 pb4 mb3 clearfix" + > + <div + className={callToActionClassNames} + style={{fontFamily: 'Roboto Mono', color: 'white', lineHeight: isSmallScreen ? 1.7 : 3}} + > + Get started on building the decentralized future + </div> + <div className="col lg-col-4 md-col-4 col-12 sm-center sm-pt2"> + <Link to={WebsitePaths.ZeroExJs} className="text-decoration-none"> + <RaisedButton + style={{borderRadius: 6, minWidth: 150}} + buttonStyle={lightButtonStyle} + labelColor={colors.white} + backgroundColor={CUSTOM_HERO_BACKGROUND_COLOR} + labelStyle={buttonLabelStyle} + label="Build on 0x" + onClick={_.noop} + /> + </Link> + </div> + </div> + </div> + ); + } + private updateScreenWidth() { + const newScreenWidth = utils.getScreenWidth(); + if (newScreenWidth !== this.state.screenWidth) { + this.setState({ + screenWidth: newScreenWidth, + }); + } + } +} diff --git a/packages/website/ts/pages/not_found.tsx b/packages/website/ts/pages/not_found.tsx new file mode 100644 index 000000000..ddd720c97 --- /dev/null +++ b/packages/website/ts/pages/not_found.tsx @@ -0,0 +1,46 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {Styles} from 'ts/types'; +import {Link} from 'react-router-dom'; +import {Footer} from 'ts/components/footer'; +import {TopBar} from 'ts/components/top_bar'; + +export interface NotFoundProps { + location: Location; +} + +interface NotFoundState {} + +const styles: Styles = { + thin: { + fontWeight: 100, + }, +}; + +export class NotFound extends React.Component<NotFoundProps, NotFoundState> { + public render() { + return ( + <div> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + /> + <div className="mx-auto max-width-4 py4"> + <div className="center py4"> + <div className="py4"> + <div className="py4"> + <h1 style={{...styles.thin}}>404 Not Found</h1> + <div className="py1"> + <div className="py3"> + Hm... looks like we couldn't find what you are looking for. + </div> + </div> + </div> + </div> + </div> + </div> + <Footer location={this.props.location} /> + </div> + ); + } +} diff --git a/packages/website/ts/pages/shared/anchor_title.tsx b/packages/website/ts/pages/shared/anchor_title.tsx new file mode 100644 index 000000000..dfa9401ae --- /dev/null +++ b/packages/website/ts/pages/shared/anchor_title.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import {Styles, HeaderSizes} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {Link as ScrollLink} from 'react-scroll'; + +const headerSizeToScrollOffset: {[headerSize: string]: number} = { + h2: -20, + h3: 0, +}; + +interface AnchorTitleProps { + title: string|React.ReactNode; + id: string; + headerSize: HeaderSizes; + shouldShowAnchor: boolean; +} + +interface AnchorTitleState { + isHovering: boolean; +} + +const styles: Styles = { + anchor: { + fontSize: 20, + transform: 'rotate(45deg)', + cursor: 'pointer', + }, + headers: { + WebkitMarginStart: 0, + WebkitMarginEnd: 0, + fontWeight: 'bold', + display: 'block', + }, + h1: { + fontSize: '1.8em', + WebkitMarginBefore: '0.83em', + WebkitMarginAfter: '0.83em', + }, + h2: { + fontSize: '1.5em', + WebkitMarginBefore: '0.83em', + WebkitMarginAfter: '0.83em', + }, + h3: { + fontSize: '1.17em', + WebkitMarginBefore: '1em', + WebkitMarginAfter: '1em', + }, +}; + +export class AnchorTitle extends React.Component<AnchorTitleProps, AnchorTitleState> { + constructor(props: AnchorTitleProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public render() { + let opacity = 0; + if (this.props.shouldShowAnchor) { + if (this.state.isHovering) { + opacity = 0.6; + } else { + opacity = 1; + } + } + return ( + <div className="relative flex" style={{...styles[this.props.headerSize], ...styles.headers}}> + <div + className="inline-block" + style={{paddingRight: 4}} + > + {this.props.title} + </div> + <ScrollLink + to={this.props.id} + offset={headerSizeToScrollOffset[this.props.headerSize]} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + <i + className="zmdi zmdi-link" + onClick={utils.setUrlHash.bind(utils, this.props.id)} + style={{...styles.anchor, opacity}} + onMouseOver={this.setHoverState.bind(this, true)} + onMouseOut={this.setHoverState.bind(this, false)} + /> + </ScrollLink> + </div> + ); + } + private setHoverState(isHovering: boolean) { + this.setState({ + isHovering, + }); + } +} diff --git a/packages/website/ts/pages/shared/markdown_code_block.tsx b/packages/website/ts/pages/shared/markdown_code_block.tsx new file mode 100644 index 000000000..621e5b606 --- /dev/null +++ b/packages/website/ts/pages/shared/markdown_code_block.tsx @@ -0,0 +1,20 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as HighLight from 'react-highlight'; + +interface MarkdownCodeBlockProps { + literal: string; + language: string; +} + +export function MarkdownCodeBlock(props: MarkdownCodeBlockProps) { + return ( + <span style={{fontSize: 16}}> + <HighLight + className={props.language || 'js'} + > + {props.literal} + </HighLight> + </span> + ); +} diff --git a/packages/website/ts/pages/shared/markdown_section.tsx b/packages/website/ts/pages/shared/markdown_section.tsx new file mode 100644 index 000000000..32b55abc8 --- /dev/null +++ b/packages/website/ts/pages/shared/markdown_section.tsx @@ -0,0 +1,77 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import {Element as ScrollElement} from 'react-scroll'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {utils} from 'ts/utils/utils'; +import {MarkdownCodeBlock} from 'ts/pages/shared/markdown_code_block'; +import RaisedButton from 'material-ui/RaisedButton'; +import {HeaderSizes} from 'ts/types'; + +interface MarkdownSectionProps { + sectionName: string; + markdownContent: string; + headerSize?: HeaderSizes; + githubLink?: string; +} + +interface MarkdownSectionState { + shouldShowAnchor: boolean; +} + +export class MarkdownSection extends React.Component<MarkdownSectionProps, MarkdownSectionState> { + public static defaultProps: Partial<MarkdownSectionProps> = { + headerSize: HeaderSizes.H3, + }; + constructor(props: MarkdownSectionProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const sectionName = this.props.sectionName; + const id = utils.getIdFromName(sectionName); + return ( + <div + className="pt2 pr3 md-pl2 sm-pl3 overflow-hidden" + onMouseOver={this.setAnchorVisibility.bind(this, true)} + onMouseOut={this.setAnchorVisibility.bind(this, false)} + > + <ScrollElement name={id}> + <div className="clearfix"> + <div className="col lg-col-8 md-col-8 sm-col-12"> + <span style={{textTransform: 'capitalize'}}> + <AnchorTitle + headerSize={this.props.headerSize} + title={sectionName} + id={id} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + </span> + </div> + <div className="col col-4 sm-hide xs-hide py2 right-align"> + {!_.isUndefined(this.props.githubLink) && + <RaisedButton + href={this.props.githubLink} + target="_blank" + label="Edit on Github" + icon={<i className="zmdi zmdi-github" style={{fontSize: 23}} />} + /> + } + </div> + </div> + <ReactMarkdown + source={this.props.markdownContent} + renderers={{CodeBlock: MarkdownCodeBlock}} + /> + </ScrollElement> + </div> + ); + } + private setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/website/ts/pages/shared/nested_sidebar_menu.tsx b/packages/website/ts/pages/shared/nested_sidebar_menu.tsx new file mode 100644 index 000000000..e69506bb8 --- /dev/null +++ b/packages/website/ts/pages/shared/nested_sidebar_menu.tsx @@ -0,0 +1,163 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import MenuItem from 'material-ui/MenuItem'; +import {colors} from 'material-ui/styles'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {VersionDropDown} from 'ts/pages/shared/version_drop_down'; +import {ZeroExJsDocSections, Styles, MenuSubsectionsBySection, Docs} from 'ts/types'; +import {typeDocUtils} from 'ts/utils/typedoc_utils'; +import {Link as ScrollLink} from 'react-scroll'; + +interface NestedSidebarMenuProps { + topLevelMenu: {[topLevel: string]: string[]}; + menuSubsectionsBySection: MenuSubsectionsBySection; + shouldDisplaySectionHeaders?: boolean; + onMenuItemClick?: () => void; + selectedVersion?: string; + versions?: string[]; + doc?: Docs; + isSectionHeaderClickable?: boolean; +} + +interface NestedSidebarMenuState {} + +const styles: Styles = { + menuItemWithHeaders: { + minHeight: 0, + }, + menuItemWithoutHeaders: { + minHeight: 48, + }, + menuItemInnerDivWithHeaders: { + lineHeight: 2, + }, +}; + +export class NestedSidebarMenu extends React.Component<NestedSidebarMenuProps, NestedSidebarMenuState> { + public static defaultProps: Partial<NestedSidebarMenuProps> = { + shouldDisplaySectionHeaders: true, + onMenuItemClick: _.noop, + }; + public render() { + const navigation = _.map(this.props.topLevelMenu, (menuItems: string[], sectionName: string) => { + const finalSectionName = sectionName.replace(/-/g, ' '); + if (this.props.shouldDisplaySectionHeaders) { + const id = utils.getIdFromName(sectionName); + return ( + <div + key={`section-${sectionName}`} + className="py1" + > + <ScrollLink + to={id} + offset={-20} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + <div + style={{color: colors.grey500, cursor: 'pointer'}} + className="pb1" + > + {finalSectionName.toUpperCase()} + </div> + </ScrollLink> + {this.renderMenuItems(menuItems)} + </div> + ); + } else { + return ( + <div key={`section-${sectionName}`} > + {this.renderMenuItems(menuItems)} + </div> + ); + } + }); + return ( + <div> + {!_.isUndefined(this.props.versions) && + !_.isUndefined(this.props.selectedVersion) && + !_.isUndefined(this.props.doc) && + <VersionDropDown + selectedVersion={this.props.selectedVersion} + versions={this.props.versions} + doc={this.props.doc} + /> + } + {navigation} + </div> + ); + } + private renderMenuItems(menuItemNames: string[]): React.ReactNode[] { + const menuItemStyles = this.props.shouldDisplaySectionHeaders ? + styles.menuItemWithHeaders : + styles.menuItemWithoutHeaders; + const menuItemInnerDivStyles = this.props.shouldDisplaySectionHeaders ? + styles.menuItemInnerDivWithHeaders : {}; + const menuItems = _.map(menuItemNames, menuItemName => { + const id = utils.getIdFromName(menuItemName); + return ( + <div key={menuItemName}> + <ScrollLink + key={`menuItem-${menuItemName}`} + to={id} + offset={-10} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + <MenuItem + onTouchTap={this.onMenuItemClick.bind(this, menuItemName)} + style={menuItemStyles} + innerDivStyle={menuItemInnerDivStyles} + > + <span style={{textTransform: 'capitalize'}}> + {menuItemName} + </span> + </MenuItem> + </ScrollLink> + {this.renderMenuItemSubsections(menuItemName)} + </div> + ); + }); + return menuItems; + } + private renderMenuItemSubsections(menuItemName: string): React.ReactNode { + if (_.isUndefined(this.props.menuSubsectionsBySection[menuItemName])) { + return null; + } + return this.renderMenuSubsectionsBySection(menuItemName, this.props.menuSubsectionsBySection[menuItemName]); + } + private renderMenuSubsectionsBySection(menuItemName: string, entityNames: string[]): React.ReactNode { + return ( + <ul style={{margin: 0, listStyleType: 'none', paddingLeft: 0}} key={menuItemName}> + {_.map(entityNames, entityName => { + const id = utils.getIdFromName(entityName); + return ( + <li key={`menuItem-${entityName}`}> + <ScrollLink + to={id} + offset={0} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + onTouchTap={this.onMenuItemClick.bind(this, entityName)} + > + <MenuItem + onTouchTap={this.onMenuItemClick.bind(this, menuItemName)} + style={{minHeight: 35}} + innerDivStyle={{paddingLeft: 36, fontSize: 14, lineHeight: '35px'}} + > + {entityName} + </MenuItem> + </ScrollLink> + </li> + ); + })} + </ul> + ); + } + private onMenuItemClick(menuItemName: string): void { + const id = utils.getIdFromName(menuItemName); + utils.setUrlHash(id); + this.props.onMenuItemClick(); + } +} diff --git a/packages/website/ts/pages/shared/section_header.tsx b/packages/website/ts/pages/shared/section_header.tsx new file mode 100644 index 000000000..5937be13b --- /dev/null +++ b/packages/website/ts/pages/shared/section_header.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import {Element as ScrollElement} from 'react-scroll'; +import {AnchorTitle} from 'ts/pages/shared/anchor_title'; +import {utils} from 'ts/utils/utils'; +import {HeaderSizes} from 'ts/types'; + +interface SectionHeaderProps { + sectionName: string; + headerSize?: HeaderSizes; +} + +interface SectionHeaderState { + shouldShowAnchor: boolean; +} + +export class SectionHeader extends React.Component<SectionHeaderProps, SectionHeaderState> { + public static defaultProps: Partial<SectionHeaderProps> = { + headerSize: HeaderSizes.H2, + }; + constructor(props: SectionHeaderProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const sectionName = this.props.sectionName.replace(/-/g, ' '); + const id = utils.getIdFromName(sectionName); + return ( + <div + onMouseOver={this.setAnchorVisibility.bind(this, true)} + onMouseOut={this.setAnchorVisibility.bind(this, false)} + > + <ScrollElement name={id}> + <AnchorTitle + headerSize={this.props.headerSize} + title={<span style={{textTransform: 'capitalize'}}>{sectionName}</span>} + id={id} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + </ScrollElement> + </div> + ); + } + private setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/website/ts/pages/shared/version_drop_down.tsx b/packages/website/ts/pages/shared/version_drop_down.tsx new file mode 100644 index 000000000..f29547c9c --- /dev/null +++ b/packages/website/ts/pages/shared/version_drop_down.tsx @@ -0,0 +1,46 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import MenuItem from 'material-ui/MenuItem'; +import DropDownMenu from 'material-ui/DropDownMenu'; +import {constants} from 'ts/utils/constants'; +import {Docs} from 'ts/types'; + +interface VersionDropDownProps { + selectedVersion: string; + versions: string[]; + doc: Docs; +} + +interface VersionDropDownState {} + +export class VersionDropDown extends React.Component<VersionDropDownProps, VersionDropDownState> { + public render() { + return ( + <div className="mx-auto" style={{width: 120}}> + <DropDownMenu + maxHeight={300} + value={this.props.selectedVersion} + onChange={this.updateSelectedVersion.bind(this)} + > + {this.renderDropDownItems()} + </DropDownMenu> + </div> + ); + } + private renderDropDownItems() { + const items = _.map(this.props.versions, version => { + return ( + <MenuItem + key={version} + value={version} + primaryText={`v${version}`} + /> + ); + }); + return items; + } + private updateSelectedVersion(e: any, index: number, value: string) { + const docPath = constants.docToPath[this.props.doc]; + window.location.href = `${docPath}/${value}${window.location.hash}`; + } +} diff --git a/packages/website/ts/pages/wiki/wiki.tsx b/packages/website/ts/pages/wiki/wiki.tsx new file mode 100644 index 000000000..0e6fc98ab --- /dev/null +++ b/packages/website/ts/pages/wiki/wiki.tsx @@ -0,0 +1,210 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import DocumentTitle = require('react-document-title'); +import {colors} from 'material-ui/styles'; +import CircularProgress from 'material-ui/CircularProgress'; +import { + scroller, +} from 'react-scroll'; +import {Styles, Article, ArticlesBySection} from 'ts/types'; +import {TopBar} from 'ts/components/top_bar'; +import {HeaderSizes, WebsitePaths} from 'ts/types'; +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import {NestedSidebarMenu} from 'ts/pages/shared/nested_sidebar_menu'; +import {SectionHeader} from 'ts/pages/shared/section_header'; +import {MarkdownSection} from 'ts/pages/shared/markdown_section'; + +const WIKI_NOT_READY_BACKOUT_TIMEOUT_MS = 5000; + +export interface WikiProps { + source: string; + location: Location; +} + +interface WikiState { + articlesBySection: ArticlesBySection; +} + +const styles: Styles = { + mainContainers: { + position: 'absolute', + top: 60, + left: 0, + bottom: 0, + right: 0, + overflowZ: 'hidden', + overflowY: 'scroll', + minHeight: 'calc(100vh - 60px)', + WebkitOverflowScrolling: 'touch', + }, + menuContainer: { + borderColor: colors.grey300, + maxWidth: 330, + marginLeft: 20, + }, +}; + +export class Wiki extends React.Component<WikiProps, WikiState> { + private wikiBackoffTimeoutId: number; + constructor(props: WikiProps) { + super(props); + this.state = { + articlesBySection: undefined, + }; + } + public componentWillMount() { + this.fetchArticlesBySectionAsync(); + } + public componentWillUnmount() { + clearTimeout(this.wikiBackoffTimeoutId); + } + public render() { + const menuSubsectionsBySection = _.isUndefined(this.state.articlesBySection) + ? {} + : this.getMenuSubsectionsBySection(this.state.articlesBySection); + return ( + <div> + <DocumentTitle title="0x Protocol Wiki"/> + <TopBar + blockchainIsLoaded={false} + location={this.props.location} + menuSubsectionsBySection={menuSubsectionsBySection} + shouldFullWidth={true} + /> + {_.isUndefined(this.state.articlesBySection) ? + <div + className="col col-12" + style={styles.mainContainers} + > + <div + className="relative sm-px2 sm-pt2 sm-m1" + style={{height: 122, top: '50%', transform: 'translateY(-50%)'}} + > + <div className="center pb2"> + <CircularProgress size={40} thickness={5} /> + </div> + <div className="center pt2" style={{paddingBottom: 11}}>Loading wiki...</div> + </div> + </div> : + <div + className="mx-auto flex" + style={{color: colors.grey800, height: 43}} + > + <div className="relative col md-col-3 lg-col-3 lg-pl0 md-pl1 sm-hide xs-hide"> + <div + className="border-right absolute pt2" + style={{...styles.menuContainer, ...styles.mainContainers}} + > + <NestedSidebarMenu + topLevelMenu={menuSubsectionsBySection} + menuSubsectionsBySection={menuSubsectionsBySection} + isSectionHeaderClickable={true} + /> + </div> + </div> + <div className="relative col lg-col-9 md-col-9 sm-col-12 col-12"> + <div + id="documentation" + style={styles.mainContainers} + className="absolute" + > + <div id="0xProtocolWiki" /> + <h1 className="md-pl2 sm-pl3"> + <a href={constants.GITHUB_WIKI_URL} target="_blank"> + 0x Protocol Wiki + </a> + </h1> + <div id="wiki"> + {this.renderWikiArticles()} + </div> + </div> + </div> + </div> + } + </div> + ); + } + private renderWikiArticles(): React.ReactNode { + const sectionNames = _.keys(this.state.articlesBySection); + const sections = _.map(sectionNames, sectionName => this.renderSection(sectionName)); + return sections; + } + private renderSection(sectionName: string) { + const articles = this.state.articlesBySection[sectionName]; + const renderedArticles = _.map(articles, (article: Article) => { + const githubLink = `${constants.GITHUB_WIKI_URL}/edit/master/${sectionName}/${article.fileName}`; + return ( + <div key={`markdown-section-${article.title}`}> + <MarkdownSection + sectionName={article.title} + markdownContent={article.content} + headerSize={HeaderSizes.H2} + githubLink={githubLink} + /> + <div className="mb4 mt3 p3 center" style={{backgroundColor: '#f9f5ef'}}> + See a way to make this article better?{' '} + <a + href={githubLink} + target="_blank" + > + Edit here → + </a> + </div> + </div> + ); + }); + return ( + <div + key={`section-${sectionName}`} + className="py2 pr3 md-pl2 sm-pl3" + > + <SectionHeader sectionName={sectionName} headerSize={HeaderSizes.H1} /> + {renderedArticles} + </div> + ); + } + private scrollToHash(): void { + const hashWithPrefix = this.props.location.hash; + let hash = hashWithPrefix.slice(1); + if (_.isEmpty(hash)) { + hash = '0xProtocolWiki'; // scroll to the top + } + + scroller.scrollTo(hash, {duration: 0, offset: 0, containerId: 'documentation'}); + } + private async fetchArticlesBySectionAsync(): Promise<void> { + const endpoint = `${configs.BACKEND_BASE_URL}${WebsitePaths.Wiki}`; + const response = await fetch(endpoint); + if (response.status === constants.HTTP_NO_CONTENT_STATUS_CODE) { + // We need to backoff and try fetching again later + this.wikiBackoffTimeoutId = window.setTimeout(() => { + this.fetchArticlesBySectionAsync(); + }, WIKI_NOT_READY_BACKOUT_TIMEOUT_MS); + return; + } + if (response.status !== 200) { + // TODO: Show the user an error message when the wiki fail to load + const errMsg = await response.text(); + utils.consoleLog(`Failed to load wiki: ${response.status} ${errMsg}`); + return; + } + const articlesBySection = await response.json(); + this.setState({ + articlesBySection, + }, () => { + this.scrollToHash(); + }); + } + private getMenuSubsectionsBySection(articlesBySection: ArticlesBySection) { + const sectionNames = _.keys(articlesBySection); + const menuSubsectionsBySection: {[section: string]: string[]} = {}; + for (const sectionName of sectionNames) { + const articles = articlesBySection[sectionName]; + const articleNames = _.map(articles, article => article.title); + menuSubsectionsBySection[sectionName] = articleNames; + } + return menuSubsectionsBySection; + } +} diff --git a/packages/website/ts/redux/dispatcher.ts b/packages/website/ts/redux/dispatcher.ts new file mode 100644 index 000000000..566ab8a01 --- /dev/null +++ b/packages/website/ts/redux/dispatcher.ts @@ -0,0 +1,244 @@ +import {Dispatch} from 'redux'; +import {State} from 'ts/redux/reducer'; +import { + Direction, + Side, + AssetToken, + BlockchainErrs, + Token, + SignatureData, + Fill, + Order, + ActionTypes, + ScreenWidths, + ProviderType, + TokenStateByAddress, +} from 'ts/types'; +import BigNumber from 'bignumber.js'; + +export class Dispatcher { + private dispatch: Dispatch<State>; + constructor(dispatch: Dispatch<State>) { + this.dispatch = dispatch; + } + // Portal + public resetState() { + this.dispatch({ + type: ActionTypes.RESET_STATE, + }); + } + public updateNodeVersion(nodeVersion: string) { + this.dispatch({ + data: nodeVersion, + type: ActionTypes.UPDATE_NODE_VERSION, + }); + } + public updateScreenWidth(screenWidth: ScreenWidths) { + this.dispatch({ + data: screenWidth, + type: ActionTypes.UPDATE_SCREEN_WIDTH, + }); + } + public swapAssetTokenSymbols() { + this.dispatch({ + type: ActionTypes.SWAP_ASSET_TOKENS, + }); + } + public updateGenerateOrderStep(direction: Direction) { + this.dispatch({ + data: direction, + type: ActionTypes.UPDATE_GENERATE_ORDER_STEP, + }); + } + public updateOrderSalt(salt: BigNumber) { + this.dispatch({ + data: salt, + type: ActionTypes.UPDATE_ORDER_SALT, + }); + } + public updateUserSuppliedOrderCache(order: Order) { + this.dispatch({ + data: order, + type: ActionTypes.UPDATE_USER_SUPPLIED_ORDER_CACHE, + }); + } + public updateShouldBlockchainErrDialogBeOpen(shouldBeOpen: boolean) { + this.dispatch({ + data: shouldBeOpen, + type: ActionTypes.UPDATE_SHOULD_BLOCKCHAIN_ERR_DIALOG_BE_OPEN, + }); + } + public updateChosenAssetToken(side: Side, token: AssetToken) { + this.dispatch({ + data: { + side, + token, + }, + type: ActionTypes.UPDATE_CHOSEN_ASSET_TOKEN, + }); + } + public updateChosenAssetTokenAddress(side: Side, address: string) { + this.dispatch({ + data: { + address, + side, + }, + type: ActionTypes.UPDATE_CHOSEN_ASSET_TOKEN_ADDRESS, + }); + } + public updateOrderTakerAddress(address: string) { + this.dispatch({ + data: address, + type: ActionTypes.UPDATE_ORDER_TAKER_ADDRESS, + }); + } + public updateUserAddress(address: string) { + this.dispatch({ + data: address, + type: ActionTypes.UPDATE_USER_ADDRESS, + }); + } + public updateOrderExpiry(unixTimestampSec: BigNumber) { + this.dispatch({ + data: unixTimestampSec, + type: ActionTypes.UPDATE_ORDER_EXPIRY, + }); + } + public encounteredBlockchainError(err: BlockchainErrs) { + this.dispatch({ + data: err, + type: ActionTypes.BLOCKCHAIN_ERR_ENCOUNTERED, + }); + } + public updateBlockchainIsLoaded(isLoaded: boolean) { + this.dispatch({ + data: isLoaded, + type: ActionTypes.UPDATE_BLOCKCHAIN_IS_LOADED, + }); + } + public addTokenToTokenByAddress(token: Token) { + this.dispatch({ + data: token, + type: ActionTypes.ADD_TOKEN_TO_TOKEN_BY_ADDRESS, + }); + } + public removeTokenToTokenByAddress(token: Token) { + this.dispatch({ + data: token, + type: ActionTypes.REMOVE_TOKEN_TO_TOKEN_BY_ADDRESS, + }); + } + public clearTokenByAddress() { + this.dispatch({ + type: ActionTypes.CLEAR_TOKEN_BY_ADDRESS, + }); + } + public updateTokenByAddress(tokens: Token[]) { + this.dispatch({ + data: tokens, + type: ActionTypes.UPDATE_TOKEN_BY_ADDRESS, + }); + } + public updateTokenStateByAddress(tokenStateByAddress: TokenStateByAddress) { + this.dispatch({ + data: tokenStateByAddress, + type: ActionTypes.UPDATE_TOKEN_STATE_BY_ADDRESS, + }); + } + public removeFromTokenStateByAddress(tokenAddress: string) { + this.dispatch({ + data: tokenAddress, + type: ActionTypes.REMOVE_FROM_TOKEN_STATE_BY_ADDRESS, + }); + } + public replaceTokenAllowanceByAddress(address: string, allowance: BigNumber) { + this.dispatch({ + data: { + address, + allowance, + }, + type: ActionTypes.REPLACE_TOKEN_ALLOWANCE_BY_ADDRESS, + }); + } + public replaceTokenBalanceByAddress(address: string, balance: BigNumber) { + this.dispatch({ + data: { + address, + balance, + }, + type: ActionTypes.REPLACE_TOKEN_BALANCE_BY_ADDRESS, + }); + } + public updateTokenBalanceByAddress(address: string, balanceDelta: BigNumber) { + this.dispatch({ + data: { + address, + balanceDelta, + }, + type: ActionTypes.UPDATE_TOKEN_BALANCE_BY_ADDRESS, + }); + } + public updateSignatureData(signatureData: SignatureData) { + this.dispatch({ + data: signatureData, + type: ActionTypes.UPDATE_ORDER_SIGNATURE_DATA, + }); + } + public updateUserEtherBalance(balance: BigNumber) { + this.dispatch({ + data: balance, + type: ActionTypes.UPDATE_USER_ETHER_BALANCE, + }); + } + public updateNetworkId(networkId: number) { + this.dispatch({ + data: networkId, + type: ActionTypes.UPDATE_NETWORK_ID, + }); + } + public updateOrderFillAmount(amount: BigNumber) { + this.dispatch({ + data: amount, + type: ActionTypes.UPDATE_ORDER_FILL_AMOUNT, + }); + } + + // Docs + public updateCurrentDocsVersion(version: string) { + this.dispatch({ + data: version, + type: ActionTypes.UPDATE_LIBRARY_VERSION, + }); + } + public updateAvailableDocVersions(versions: string[]) { + this.dispatch({ + data: versions, + type: ActionTypes.UPDATE_AVAILABLE_LIBRARY_VERSIONS, + }); + } + + // Shared + public showFlashMessage(msg: string|React.ReactNode) { + this.dispatch({ + data: msg, + type: ActionTypes.SHOW_FLASH_MESSAGE, + }); + } + public hideFlashMessage() { + this.dispatch({ + type: ActionTypes.HIDE_FLASH_MESSAGE, + }); + } + public updateProviderType(providerType: ProviderType) { + this.dispatch({ + type: ActionTypes.UPDATE_PROVIDER_TYPE, + data: providerType, + }); + } + public updateInjectedProviderName(injectedProviderName: string) { + this.dispatch({ + type: ActionTypes.UPDATE_INJECTED_PROVIDER_NAME, + data: injectedProviderName, + }); + } +} diff --git a/packages/website/ts/redux/reducer.ts b/packages/website/ts/redux/reducer.ts new file mode 100644 index 000000000..7723597cd --- /dev/null +++ b/packages/website/ts/redux/reducer.ts @@ -0,0 +1,363 @@ +import * as _ from 'lodash'; +import {ZeroEx} from '0x.js'; +import BigNumber from 'bignumber.js'; +import {utils} from 'ts/utils/utils'; +import { + GenerateOrderSteps, + Side, + SideToAssetToken, + Direction, + BlockchainErrs, + SignatureData, + TokenByAddress, + TokenStateByAddress, + Order, + Action, + ActionTypes, + ScreenWidths, + ProviderType, + TokenState, +} from 'ts/types'; + +// Instead of defaulting the docs version to an empty string, we pre-populate it with +// a valid version value. This does not need to be updated however, since onLoad, it +// is always replaced with a value retrieved from our S3 bucket. +const DEFAULT_DOCS_VERSION = '0.0.0'; + +export interface State { + // Portal + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + generateOrderStep: GenerateOrderSteps; + networkId: number; + orderExpiryTimestamp: BigNumber; + orderFillAmount: BigNumber; + orderTakerAddress: string; + orderSignatureData: SignatureData; + orderSalt: BigNumber; + nodeVersion: string; + screenWidth: ScreenWidths; + shouldBlockchainErrDialogBeOpen: boolean; + sideToAssetToken: SideToAssetToken; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; + userAddress: string; + userEtherBalance: BigNumber; + // Note: cache of supplied orderJSON in fill order step. Do not use for anything else. + userSuppliedOrderCache: Order; + + // Docs + docsVersion: string; + availableDocVersions: string[]; + + // Shared + flashMessage: string|React.ReactNode; + providerType: ProviderType; + injectedProviderName: string; +}; + +const INITIAL_STATE: State = { + // Portal + blockchainErr: '', + blockchainIsLoaded: false, + generateOrderStep: GenerateOrderSteps.ChooseAssets, + networkId: undefined, + orderExpiryTimestamp: utils.initialOrderExpiryUnixTimestampSec(), + orderFillAmount: undefined, + orderSignatureData: { + hash: '', + r: '', + s: '', + v: 27, + }, + orderTakerAddress: '', + orderSalt: ZeroEx.generatePseudoRandomSalt(), + nodeVersion: undefined, + screenWidth: utils.getScreenWidth(), + shouldBlockchainErrDialogBeOpen: false, + sideToAssetToken: { + [Side.deposit]: {}, + [Side.receive]: {}, + }, + tokenByAddress: {}, + tokenStateByAddress: {}, + userAddress: '', + userEtherBalance: new BigNumber(0), + userSuppliedOrderCache: undefined, + + // Docs + docsVersion: DEFAULT_DOCS_VERSION, + availableDocVersions: [DEFAULT_DOCS_VERSION], + + // Shared + flashMessage: undefined, + providerType: ProviderType.INJECTED, + injectedProviderName: '', +}; + +export function reducer(state: State = INITIAL_STATE, action: Action) { + switch (action.type) { + // Portal + case ActionTypes.RESET_STATE: + return INITIAL_STATE; + + case ActionTypes.UPDATE_ORDER_SALT: { + return _.assign({}, state, { + orderSalt: action.data, + }); + } + + case ActionTypes.UPDATE_NODE_VERSION: { + return _.assign({}, state, { + nodeVersion: action.data, + }); + } + + case ActionTypes.UPDATE_ORDER_FILL_AMOUNT: { + return _.assign({}, state, { + orderFillAmount: action.data, + }); + } + + case ActionTypes.UPDATE_SHOULD_BLOCKCHAIN_ERR_DIALOG_BE_OPEN: { + return _.assign({}, state, { + shouldBlockchainErrDialogBeOpen: action.data, + }); + } + + case ActionTypes.UPDATE_USER_ETHER_BALANCE: { + return _.assign({}, state, { + userEtherBalance: action.data, + }); + } + + case ActionTypes.UPDATE_USER_SUPPLIED_ORDER_CACHE: { + return _.assign({}, state, { + userSuppliedOrderCache: action.data, + }); + } + + case ActionTypes.CLEAR_TOKEN_BY_ADDRESS: { + return _.assign({}, state, { + tokenByAddress: {}, + }); + } + + case ActionTypes.ADD_TOKEN_TO_TOKEN_BY_ADDRESS: { + const newTokenByAddress = state.tokenByAddress; + newTokenByAddress[action.data.address] = action.data; + return _.assign({}, state, { + tokenByAddress: newTokenByAddress, + }); + } + + case ActionTypes.REMOVE_TOKEN_TO_TOKEN_BY_ADDRESS: { + const newTokenByAddress = state.tokenByAddress; + delete newTokenByAddress[action.data.address]; + return _.assign({}, state, { + tokenByAddress: newTokenByAddress, + }); + } + + case ActionTypes.UPDATE_TOKEN_BY_ADDRESS: { + const tokenByAddress = state.tokenByAddress; + const tokens = action.data; + _.each(tokens, token => { + const updatedToken = _.assign({}, tokenByAddress[token.address], token); + tokenByAddress[token.address] = updatedToken; + }); + return _.assign({}, state, { + tokenByAddress, + }); + } + + case ActionTypes.UPDATE_TOKEN_STATE_BY_ADDRESS: { + const tokenStateByAddress = state.tokenStateByAddress; + const updatedTokenStateByAddress = action.data; + _.each(updatedTokenStateByAddress, (tokenState: TokenState, address: string) => { + const updatedTokenState = _.assign({}, tokenStateByAddress[address], tokenState); + tokenStateByAddress[address] = updatedTokenState; + }); + return _.assign({}, state, { + tokenStateByAddress, + }); + } + + case ActionTypes.REMOVE_FROM_TOKEN_STATE_BY_ADDRESS: { + const tokenStateByAddress = state.tokenStateByAddress; + const tokenAddress = action.data; + delete tokenStateByAddress[tokenAddress]; + return _.assign({}, state, { + tokenStateByAddress, + }); + } + + case ActionTypes.REPLACE_TOKEN_ALLOWANCE_BY_ADDRESS: { + const tokenStateByAddress = state.tokenStateByAddress; + const allowance = action.data.allowance; + const tokenAddress = action.data.address; + tokenStateByAddress[tokenAddress] = _.assign({}, tokenStateByAddress[tokenAddress], { + allowance, + }); + return _.assign({}, state, { + tokenStateByAddress, + }); + } + + case ActionTypes.REPLACE_TOKEN_BALANCE_BY_ADDRESS: { + const tokenStateByAddress = state.tokenStateByAddress; + const balance = action.data.balance; + const tokenAddress = action.data.address; + tokenStateByAddress[tokenAddress] = _.assign({}, tokenStateByAddress[tokenAddress], { + balance, + }); + return _.assign({}, state, { + tokenStateByAddress, + }); + } + + case ActionTypes.UPDATE_TOKEN_BALANCE_BY_ADDRESS: { + const tokenStateByAddress = state.tokenStateByAddress; + const balanceDelta = action.data.balanceDelta; + const tokenAddress = action.data.address; + const currBalance = tokenStateByAddress[tokenAddress].balance; + tokenStateByAddress[tokenAddress] = _.assign({}, tokenStateByAddress[tokenAddress], { + balance: currBalance.plus(balanceDelta), + }); + return _.assign({}, state, { + tokenStateByAddress, + }); + } + + case ActionTypes.UPDATE_ORDER_SIGNATURE_DATA: { + return _.assign({}, state, { + orderSignatureData: action.data, + }); + } + + case ActionTypes.UPDATE_SCREEN_WIDTH: { + return _.assign({}, state, { + screenWidth: action.data, + }); + } + + case ActionTypes.UPDATE_BLOCKCHAIN_IS_LOADED: { + return _.assign({}, state, { + blockchainIsLoaded: action.data, + }); + } + + case ActionTypes.BLOCKCHAIN_ERR_ENCOUNTERED: { + return _.assign({}, state, { + blockchainErr: action.data, + }); + } + + case ActionTypes.UPDATE_NETWORK_ID: { + return _.assign({}, state, { + networkId: action.data, + }); + } + + case ActionTypes.UPDATE_GENERATE_ORDER_STEP: { + const direction = action.data; + let nextGenerateOrderStep = state.generateOrderStep; + if (direction === Direction.forward) { + nextGenerateOrderStep += 1; + } else if (state.generateOrderStep !== 0) { + nextGenerateOrderStep -= 1; + } + return _.assign({}, state, { + generateOrderStep: nextGenerateOrderStep, + }); + } + + case ActionTypes.UPDATE_CHOSEN_ASSET_TOKEN: { + const newSideToAssetToken = _.assign({}, state.sideToAssetToken, { + [action.data.side]: action.data.token, + }); + return _.assign({}, state, { + sideToAssetToken: newSideToAssetToken, + }); + } + + case ActionTypes.UPDATE_CHOSEN_ASSET_TOKEN_ADDRESS: { + const newAssetToken = state.sideToAssetToken[action.data.side]; + newAssetToken.address = action.data.address; + const newSideToAssetToken = _.assign({}, state.sideToAssetToken, { + [action.data.side]: newAssetToken, + }); + return _.assign({}, state, { + sideToAssetToken: newSideToAssetToken, + }); + } + + case ActionTypes.SWAP_ASSET_TOKENS: { + const newSideToAssetToken = _.assign({}, state.sideToAssetToken, { + [Side.deposit]: state.sideToAssetToken[Side.receive], + [Side.receive]: state.sideToAssetToken[Side.deposit], + }); + return _.assign({}, state, { + sideToAssetToken: newSideToAssetToken, + }); + } + + case ActionTypes.UPDATE_ORDER_EXPIRY: { + return _.assign({}, state, { + orderExpiryTimestamp: action.data, + }); + } + + case ActionTypes.UPDATE_ORDER_TAKER_ADDRESS: { + return _.assign({}, state, { + orderTakerAddress: action.data, + }); + } + + case ActionTypes.UPDATE_USER_ADDRESS: { + return _.assign({}, state, { + userAddress: action.data, + }); + } + + // Docs + case ActionTypes.UPDATE_LIBRARY_VERSION: { + return _.assign({}, state, { + docsVersion: action.data, + }); + } + case ActionTypes.UPDATE_AVAILABLE_LIBRARY_VERSIONS: { + return _.assign({}, state, { + availableDocVersions: action.data, + }); + } + + // Shared + case ActionTypes.SHOW_FLASH_MESSAGE: { + return _.assign({}, state, { + flashMessage: action.data, + }); + } + + case ActionTypes.HIDE_FLASH_MESSAGE: { + return _.assign({}, state, { + flashMessage: undefined, + }); + } + + case ActionTypes.UPDATE_PROVIDER_TYPE: { + return _.assign({}, state, { + providerType: action.data, + }); + } + + case ActionTypes.UPDATE_INJECTED_PROVIDER_NAME: { + return _.assign({}, state, { + injectedProviderName: action.data, + }); + } + + default: + return state; + } +} diff --git a/packages/website/ts/schemas/order_schema.ts b/packages/website/ts/schemas/order_schema.ts new file mode 100644 index 000000000..61b93d273 --- /dev/null +++ b/packages/website/ts/schemas/order_schema.ts @@ -0,0 +1,24 @@ +export const orderSchema = { + id: '/Order', + properties: { + maker: {$ref: '/OrderTaker'}, + taker: {$ref: '/OrderTaker'}, + salt: {type: 'string'}, + signature: {$ref: '/SignatureData'}, + expiration: {type: 'string'}, + feeRecipient: {type: 'string'}, + exchangeContract: {type: 'string'}, + networkId: {type: 'number'}, + }, + required: [ + 'maker', + 'taker', + 'salt', + 'signature', + 'expiration', + 'feeRecipient', + 'exchangeContract', + 'networkId', + ], + type: 'object', +}; diff --git a/packages/website/ts/schemas/order_taker_schema.ts b/packages/website/ts/schemas/order_taker_schema.ts new file mode 100644 index 000000000..6b484a60d --- /dev/null +++ b/packages/website/ts/schemas/order_taker_schema.ts @@ -0,0 +1,11 @@ +export const orderTakerSchema = { + id: '/OrderTaker', + properties: { + address: {type: 'string'}, + token: {$ref: '/Token'}, + amount: {type: 'string'}, + feeAmount: {type: 'string'}, + }, + required: ['address', 'token', 'amount', 'feeAmount'], + type: 'object', +}; diff --git a/packages/website/ts/schemas/signature_data_schema.ts b/packages/website/ts/schemas/signature_data_schema.ts new file mode 100644 index 000000000..d208cc438 --- /dev/null +++ b/packages/website/ts/schemas/signature_data_schema.ts @@ -0,0 +1,11 @@ +export const signatureDataSchema = { + id: '/SignatureData', + properties: { + hash: {type: 'string'}, + r: {type: 'string'}, + s: {type: 'string'}, + v: {type: 'number'}, + }, + required: ['hash', 'r', 's', 'v'], + type: 'object', +}; diff --git a/packages/website/ts/schemas/token_schema.ts b/packages/website/ts/schemas/token_schema.ts new file mode 100644 index 000000000..c15f57429 --- /dev/null +++ b/packages/website/ts/schemas/token_schema.ts @@ -0,0 +1,11 @@ +export const tokenSchema = { + id: '/Token', + properties: { + name: {type: 'string'}, + symbol: {type: 'string'}, + decimals: {type: 'number'}, + address: {type: 'string'}, + }, + required: ['name', 'symbol', 'decimals', 'address'], + type: 'object', +}; diff --git a/packages/website/ts/schemas/validator.ts b/packages/website/ts/schemas/validator.ts new file mode 100644 index 000000000..bf6ba4044 --- /dev/null +++ b/packages/website/ts/schemas/validator.ts @@ -0,0 +1,19 @@ +import {Validator, Schema as JSONSchema} from 'jsonschema'; +import {signatureDataSchema} from 'ts/schemas/signature_data_schema'; +import {orderSchema} from 'ts/schemas/order_schema'; +import {tokenSchema} from 'ts/schemas/token_schema'; +import {orderTakerSchema} from 'ts/schemas/order_taker_schema'; + +export class SchemaValidator { + private validator: Validator; + constructor() { + this.validator = new Validator(); + this.validator.addSchema(signatureDataSchema as JSONSchema, signatureDataSchema.id); + this.validator.addSchema(tokenSchema as JSONSchema, tokenSchema.id); + this.validator.addSchema(orderTakerSchema as JSONSchema, orderTakerSchema.id); + this.validator.addSchema(orderSchema as JSONSchema, orderSchema.id); + } + public validate(instance: object, schema: Schema) { + return this.validator.validate(instance, schema); + } +} diff --git a/packages/website/ts/subproviders/injected_web3_subprovider.ts b/packages/website/ts/subproviders/injected_web3_subprovider.ts new file mode 100644 index 000000000..b9e5af3ef --- /dev/null +++ b/packages/website/ts/subproviders/injected_web3_subprovider.ts @@ -0,0 +1,44 @@ +import * as _ from 'lodash'; +import Web3 = require('web3'); +import {constants} from 'ts/utils/constants'; + +/* + * This class implements the web3-provider-engine subprovider interface and forwards + * requests involving user accounts (getAccounts, sendTransaction, etc...) to the injected + * web3 instance in their browser. + * Source: https://github.com/MetaMask/provider-engine/blob/master/subproviders/subprovider.js + */ +export class InjectedWeb3SubProvider { + private injectedWeb3: Web3; + constructor(injectedWeb3: Web3) { + this.injectedWeb3 = injectedWeb3; + } + public handleRequest(payload: any, next: () => void, end: (err: Error, result: any) => void) { + switch (payload.method) { + case 'web3_clientVersion': + this.injectedWeb3.version.getNode(end); + return; + case 'eth_accounts': + this.injectedWeb3.eth.getAccounts(end); + return; + + case 'eth_sendTransaction': + const [txParams] = payload.params; + this.injectedWeb3.eth.sendTransaction(txParams, end); + return; + + case 'eth_sign': + const [address, message] = payload.params; + this.injectedWeb3.eth.sign(address, message, end); + return; + + default: + next(); + return; + } + } + // Required to implement this method despite not needing it for this subprovider + public setEngine(engine: any) { + // noop + } +} diff --git a/packages/website/ts/subproviders/ledger_wallet_subprovider_factory.ts b/packages/website/ts/subproviders/ledger_wallet_subprovider_factory.ts new file mode 100644 index 000000000..df0c5a4db --- /dev/null +++ b/packages/website/ts/subproviders/ledger_wallet_subprovider_factory.ts @@ -0,0 +1,172 @@ +import * as _ from 'lodash'; +import Web3 = require('web3'); +import * as EthereumTx from 'ethereumjs-tx'; +import ethUtil = require('ethereumjs-util'); +import * as ledger from 'ledgerco'; +import HookedWalletSubprovider = require('web3-provider-engine/subproviders/hooked-wallet'); +import {constants} from 'ts/utils/constants'; +import {LedgerEthConnection, SignPersonalMessageParams, TxParams} from 'ts/types'; + +const NUM_ADDRESSES_TO_FETCH = 10; +const ASK_FOR_ON_DEVICE_CONFIRMATION = false; +const SHOULD_GET_CHAIN_CODE = false; + +export class LedgerWallet { + public isU2FSupported: boolean; + public getAccounts: (callback: (err: Error, accounts: string[]) => void) => void; + public signMessage: (msgParams: SignPersonalMessageParams, + callback: (err: Error, result?: string) => void) => void; + public signTransaction: (txParams: TxParams, + callback: (err: Error, result?: string) => void) => void; + private getNetworkId: () => number; + private path: string; + private pathIndex: number; + private ledgerEthConnection: LedgerEthConnection; + private accounts: string[]; + constructor(getNetworkIdFn: () => number) { + this.path = constants.DEFAULT_DERIVATION_PATH; + this.pathIndex = 0; + this.isU2FSupported = false; + this.getNetworkId = getNetworkIdFn; + this.getAccounts = this.getAccountsAsync.bind(this); + this.signMessage = this.signPersonalMessageAsync.bind(this); + this.signTransaction = this.signTransactionAsync.bind(this); + } + public getPath(): string { + return this.path; + } + public setPath(derivationPath: string) { + this.path = derivationPath; + // HACK: Must re-assign getAccounts, signMessage and signTransaction since they were + // previously bound to old values of this.path + this.getAccounts = this.getAccountsAsync.bind(this); + this.signMessage = this.signPersonalMessageAsync.bind(this); + this.signTransaction = this.signTransactionAsync.bind(this); + } + public setPathIndex(pathIndex: number) { + this.pathIndex = pathIndex; + // HACK: Must re-assign signMessage & signTransaction since they it was previously bound to + // old values of this.path + this.signMessage = this.signPersonalMessageAsync.bind(this); + this.signTransaction = this.signTransactionAsync.bind(this); + } + public async getAccountsAsync(callback: (err: Error, accounts: string[]) => void) { + if (!_.isUndefined(this.ledgerEthConnection)) { + callback(null, []); + return; + } + this.ledgerEthConnection = await this.createLedgerConnectionAsync(); + + const accounts = []; + for (let i = 0; i < NUM_ADDRESSES_TO_FETCH; i++) { + try { + const derivationPath = `${this.path}/${i}`; + const result = await this.ledgerEthConnection.getAddress_async( + derivationPath, ASK_FOR_ON_DEVICE_CONFIRMATION, SHOULD_GET_CHAIN_CODE, + ); + accounts.push(result.address.toLowerCase()); + } catch (err) { + await this.closeLedgerConnectionAsync(); + callback(err, null); + return; + } + } + + await this.closeLedgerConnectionAsync(); + callback(null, accounts); + } + public async signTransactionAsync(txParams: TxParams, callback: (err: Error, result?: string) => void) { + const tx = new EthereumTx(txParams); + + const networkId = this.getNetworkId(); + const chainId = networkId; // Same thing + + // Set the EIP155 bits + tx.raw[6] = Buffer.from([chainId]); // v + tx.raw[7] = Buffer.from([]); // r + tx.raw[8] = Buffer.from([]); // s + + const txHex = tx.serialize().toString('hex'); + + this.ledgerEthConnection = await this.createLedgerConnectionAsync(); + + try { + const derivationPath = this.getDerivationPath(); + const result = await this.ledgerEthConnection.signTransaction_async(derivationPath, txHex); + + // Store signature in transaction + tx.v = new Buffer(result.v, 'hex'); + tx.r = new Buffer(result.r, 'hex'); + tx.s = new Buffer(result.s, 'hex'); + + // EIP155: v should be chain_id * 2 + {35, 36} + const signedChainId = Math.floor((tx.v[0] - 35) / 2); + if (signedChainId !== chainId) { + const err = new Error('TOO_OLD_LEDGER_FIRMWARE'); + callback(err, null); + return; + } + + const signedTxHex = `0x${tx.serialize().toString('hex')}`; + await this.closeLedgerConnectionAsync(); + callback(null, signedTxHex); + } catch (err) { + await this.closeLedgerConnectionAsync(); + callback(err, null); + } + } + public async signPersonalMessageAsync(msgParams: SignPersonalMessageParams, + callback: (err: Error, result?: string) => void) { + if (!_.isUndefined(this.ledgerEthConnection)) { + callback(new Error('Another request is in progress.')); + return; + } + this.ledgerEthConnection = await this.createLedgerConnectionAsync(); + + try { + const derivationPath = this.getDerivationPath(); + const result = await this.ledgerEthConnection.signPersonalMessage_async( + derivationPath, ethUtil.stripHexPrefix(msgParams.data), + ); + const v = _.parseInt(result.v) - 27; + let vHex = v.toString(16); + if (vHex.length < 2) { + vHex = `0${v}`; + } + const signature = `0x${result.r}${result.s}${vHex}`; + await this.closeLedgerConnectionAsync(); + callback(null, signature); + } catch (err) { + await this.closeLedgerConnectionAsync(); + callback(err, null); + } + } + private async createLedgerConnectionAsync() { + if (!_.isUndefined(this.ledgerEthConnection)) { + throw new Error('Multiple open connections to the Ledger disallowed.'); + } + const ledgerConnection = await ledger.comm_u2f.create_async(); + const ledgerEthConnection = new ledger.eth(ledgerConnection); + return ledgerEthConnection; + } + private async closeLedgerConnectionAsync() { + if (_.isUndefined(this.ledgerEthConnection)) { + return; + } + await this.ledgerEthConnection.comm.close_async(); + this.ledgerEthConnection = undefined; + } + private getDerivationPath() { + const derivationPath = `${this.path}/${this.pathIndex}`; + return derivationPath; + } +} + +export const ledgerWalletSubproviderFactory = (getNetworkIdFn: () => number): LedgerWallet => { + const ledgerWallet = new LedgerWallet(getNetworkIdFn); + const ledgerWalletSubprovider = new HookedWalletSubprovider(ledgerWallet) as LedgerWallet; + ledgerWalletSubprovider.getPath = ledgerWallet.getPath.bind(ledgerWallet); + ledgerWalletSubprovider.setPath = ledgerWallet.setPath.bind(ledgerWallet); + ledgerWalletSubprovider.setPathIndex = ledgerWallet.setPathIndex.bind(ledgerWallet); + return ledgerWalletSubprovider; +}; diff --git a/packages/website/ts/subproviders/redundant_rpc_subprovider.ts b/packages/website/ts/subproviders/redundant_rpc_subprovider.ts new file mode 100644 index 000000000..a6c53ebd1 --- /dev/null +++ b/packages/website/ts/subproviders/redundant_rpc_subprovider.ts @@ -0,0 +1,41 @@ +import * as _ from 'lodash'; +import {JSONRPCPayload} from 'ts/types'; +import promisify = require('es6-promisify'); +import Subprovider = require('web3-provider-engine/subproviders/subprovider'); +import RpcSubprovider = require('web3-provider-engine/subproviders/rpc'); + +export class RedundantRPCSubprovider extends Subprovider { + private rpcs: RpcSubprovider[]; + constructor(endpoints: string[]) { + super(); + this.rpcs = _.map(endpoints, endpoint => { + return new RpcSubprovider({ + rpcUrl: endpoint, + }); + }); + } + public async handleRequest(payload: JSONRPCPayload, next: () => void, + end: (err?: Error, data?: any) => void): Promise<void> { + const rpcsCopy = this.rpcs.slice(); + try { + const data = await this.firstSuccessAsync(rpcsCopy, payload, next); + end(null, data); + } catch (err) { + end(err); + } + + } + private async firstSuccessAsync(rpcs: RpcSubprovider[], payload: JSONRPCPayload, next: () => void): Promise<any> { + let lastErr; + for (const rpc of rpcs) { + try { + const data = await promisify(rpc.handleRequest.bind(rpc))(payload, next); + return data; + } catch (err) { + lastErr = err; + continue; + } + } + throw Error(lastErr); + } +} diff --git a/packages/website/ts/types.ts b/packages/website/ts/types.ts new file mode 100644 index 000000000..2d0103499 --- /dev/null +++ b/packages/website/ts/types.ts @@ -0,0 +1,692 @@ +import * as _ from 'lodash'; +import BigNumber from 'bignumber.js'; + +// Utility function to create a K:V from a list of strings +// Adapted from: https://basarat.gitbooks.io/typescript/content/docs/types/literal-types.html +function strEnum(values: string[]): {[key: string]: string} { + return _.reduce(values, (result, key) => { + result[key] = key; + return result; + }, Object.create(null)); +} + +export enum GenerateOrderSteps { + ChooseAssets, + GrantAllowance, + RemainingConfigs, + SignTransaction, + CopyAndShare, +}; + +export const Side = strEnum([ + 'receive', + 'deposit', +]); +export type Side = keyof typeof Side; + +export const BlockchainErrs = strEnum([ + 'A_CONTRACT_NOT_DEPLOYED_ON_NETWORK', + 'DISCONNECTED_FROM_ETHEREUM_NODE', + 'UNHANDLED_ERROR', +]); +export type BlockchainErrs = keyof typeof BlockchainErrs; + +export const Direction = strEnum([ + 'forward', + 'backward', +]); +export type Direction = keyof typeof Direction; + +export interface Token { + iconUrl?: string; + name: string; + address: string; + symbol: string; + decimals: number; + isTracked: boolean; + isRegistered: boolean; +}; + +export interface TokenByAddress { + [address: string]: Token; +}; + +export interface TokenState { + allowance: BigNumber; + balance: BigNumber; +} + +export interface TokenStateByAddress { + [address: string]: TokenState; +}; + +export interface AssetToken { + address?: string; + amount?: BigNumber; +} + +export interface SideToAssetToken { + [side: string]: AssetToken; +}; + +export interface SignatureData { + hash: string; + r: string; + s: string; + v: number; +}; + +export interface HashData { + depositAmount: BigNumber; + depositTokenContractAddr: string; + feeRecipientAddress: string; + makerFee: BigNumber; + orderExpiryTimestamp: BigNumber; + orderMakerAddress: string; + orderTakerAddress: string; + receiveAmount: BigNumber; + receiveTokenContractAddr: string; + takerFee: BigNumber; + orderSalt: BigNumber; +} + +export interface OrderToken { + name: string; + symbol: string; + decimals: number; + address: string; +} + +export interface OrderParty { + address: string; + token: OrderToken; + amount: string; + feeAmount: string; +} + +export interface Order { + maker: OrderParty; + taker: OrderParty; + expiration: string; + feeRecipient: string; + salt: string; + signature: SignatureData; + exchangeContract: string; + networkId: number; +} + +export interface Fill { + logIndex: number; + maker: string; + taker: string; + makerToken: string; + takerToken: string; + filledMakerTokenAmount: BigNumber; + filledTakerTokenAmount: BigNumber; + paidMakerFee: BigNumber; + paidTakerFee: BigNumber; + orderHash: string; + transactionHash: string; + blockTimestamp: number; +} + +export enum BalanceErrs { + incorrectNetworkForFaucet, + faucetRequestFailed, + faucetQueueIsFull, + mintingFailed, + wethConversionFailed, + sendFailed, + allowanceSettingFailed, +}; + +export const ActionTypes = strEnum([ + // Portal + 'UPDATE_SCREEN_WIDTH', + 'UPDATE_NODE_VERSION', + 'RESET_STATE', + 'ADD_TOKEN_TO_TOKEN_BY_ADDRESS', + 'BLOCKCHAIN_ERR_ENCOUNTERED', + 'CLEAR_TOKEN_BY_ADDRESS', + 'UPDATE_BLOCKCHAIN_IS_LOADED', + 'UPDATE_NETWORK_ID', + 'UPDATE_GENERATE_ORDER_STEP', + 'UPDATE_CHOSEN_ASSET_TOKEN', + 'UPDATE_CHOSEN_ASSET_TOKEN_ADDRESS', + 'UPDATE_ORDER_TAKER_ADDRESS', + 'UPDATE_ORDER_SALT', + 'UPDATE_ORDER_SIGNATURE_DATA', + 'UPDATE_TOKEN_BY_ADDRESS', + 'REMOVE_TOKEN_TO_TOKEN_BY_ADDRESS', + 'UPDATE_TOKEN_STATE_BY_ADDRESS', + 'REMOVE_FROM_TOKEN_STATE_BY_ADDRESS', + 'REPLACE_TOKEN_ALLOWANCE_BY_ADDRESS', + 'REPLACE_TOKEN_BALANCE_BY_ADDRESS', + 'UPDATE_TOKEN_BALANCE_BY_ADDRESS', + 'UPDATE_ORDER_EXPIRY', + 'SWAP_ASSET_TOKENS', + 'UPDATE_USER_ADDRESS', + 'UPDATE_USER_ETHER_BALANCE', + 'UPDATE_USER_SUPPLIED_ORDER_CACHE', + 'UPDATE_ORDER_FILL_AMOUNT', + 'UPDATE_SHOULD_BLOCKCHAIN_ERR_DIALOG_BE_OPEN', + + // Docs + 'UPDATE_LIBRARY_VERSION', + 'UPDATE_AVAILABLE_LIBRARY_VERSIONS', + + // Shared + 'SHOW_FLASH_MESSAGE', + 'HIDE_FLASH_MESSAGE', + 'UPDATE_PROVIDER_TYPE', + 'UPDATE_INJECTED_PROVIDER_NAME', +]); +export type ActionTypes = keyof typeof ActionTypes; + +export interface Action { + type: ActionTypes; + data?: any; +} + +export interface TrackedTokensByNetworkId { + [networkId: number]: Token; +} + +export interface Styles { + [name: string]: React.CSSProperties; +} + +export interface ProfileInfo { + name: string; + title?: string; + description: string; + image: string; + linkedIn?: string; + github?: string; + angellist?: string; + medium?: string; + twitter?: string; +} + +export interface Partner { + name: string; + logo: string; + url: string; +} + +export interface Statistic { + title: string; + figure: string; +} + +export interface StatisticByKey { + [key: string]: Statistic; +} + +export interface ERC20MarketInfo { + etherMarketCapUsd: number; + numLiquidERC20Tokens: number; + marketCapERC20TokensUsd: number; +} + +export enum ExchangeContractErrs { + OrderFillExpired = 'ORDER_FILL_EXPIRED', + OrderAlreadyCancelledOrFilled = 'ORDER_ALREADY_CANCELLED_OR_FILLED', + OrderRemainingFillAmountZero = 'ORDER_REMAINING_FILL_AMOUNT_ZERO', + OrderFillRoundingError = 'ORDER_FILL_ROUNDING_ERROR', + FillBalanceAllowanceError = 'FILL_BALANCE_ALLOWANCE_ERROR', + InsufficientTakerBalance = 'INSUFFICIENT_TAKER_BALANCE', + InsufficientTakerAllowance = 'INSUFFICIENT_TAKER_ALLOWANCE', + InsufficientMakerBalance = 'INSUFFICIENT_MAKER_BALANCE', + InsufficientMakerAllowance = 'INSUFFICIENT_MAKER_ALLOWANCE', + TransactionSenderIsNotFillOrderTaker = 'TRANSACTION_SENDER_IS_NOT_FILL_ORDER_TAKER', + InsufficientRemainingFillAmount = 'INSUFFICIENT_REMAINING_FILL_AMOUNT', +} + +export interface ContractResponse { + logs: ContractEvent[]; +} + +export interface ContractEvent { + event: string; + args: any; +} + +export type InputErrMsg = React.ReactNode | string | undefined; +export type ValidatedBigNumberCallback = (isValid: boolean, amount?: BigNumber) => void; +export const ScreenWidths = strEnum([ + 'SM', + 'MD', + 'LG', +]); +export type ScreenWidths = keyof typeof ScreenWidths; + +export enum AlertTypes { + ERROR, + SUCCESS, +} + +export const EtherscanLinkSuffixes = strEnum([ + 'address', + 'tx', +]); +export type EtherscanLinkSuffixes = keyof typeof EtherscanLinkSuffixes; + +export const BlockchainCallErrs = strEnum([ + 'CONTRACT_DOES_NOT_EXIST', + 'USER_HAS_NO_ASSOCIATED_ADDRESSES', + 'UNHANDLED_ERROR', + 'TOKEN_ADDRESS_IS_INVALID', + 'INVALID_SIGNATURE', +]); +export type BlockchainCallErrs = keyof typeof BlockchainCallErrs; + +export const KindString = strEnum([ + 'Constructor', + 'Property', + 'Method', + 'Interface', + 'Type alias', + 'Variable', + 'Function', + 'Enumeration', +]); +export type KindString = keyof typeof KindString; + +export interface EnumValue { + name: string; + defaultValue?: string; +} + +export enum Environments { + DEVELOPMENT, + PRODUCTION, +} + +export type ContractInstance = any; // TODO: add type definition for Contract + +export interface TypeDocType { + type: TypeDocTypes; + value: string; + name: string; + types: TypeDocType[]; + typeArguments?: TypeDocType[]; + declaration: TypeDocNode; + elementType?: TypeDocType; +} + +export interface TypeDocFlags { + isStatic?: boolean; + isOptional?: boolean; + isPublic?: boolean; +} + +export interface TypeDocGroup { + title: string; + children: number[]; +} + +export interface TypeDocNode { + id?: number; + name?: string; + kind?: string; + defaultValue?: string; + kindString?: string; + type?: TypeDocType; + fileName?: string; + line?: number; + comment?: TypeDocNode; + text?: string; + shortText?: string; + returns?: string; + declaration: TypeDocNode; + flags?: TypeDocFlags; + indexSignature?: TypeDocNode[]; + signatures?: TypeDocNode[]; + parameters?: TypeDocNode[]; + typeParameter?: TypeDocNode[]; + sources?: TypeDocNode[]; + children?: TypeDocNode[]; + groups?: TypeDocGroup[]; +} + +export enum TypeDocTypes { + Intrinsic = 'intrinsic', + Reference = 'reference', + Array = 'array', + StringLiteral = 'stringLiteral', + Reflection = 'reflection', + Union = 'union', + TypeParameter = 'typeParameter', + Unknown = 'unknown', +} + +export interface DocAgnosticFormat { + [sectionName: string]: DocSection; +} + +export interface DocSection { + comment: string; + constructors: Array<TypescriptMethod|SolidityMethod>; + methods: Array<TypescriptMethod|SolidityMethod>; + properties: Property[]; + types: CustomType[]; + events?: Event[]; +} + +export interface Event { + name: string; + eventArgs: EventArg[]; +} + +export interface EventArg { + isIndexed: boolean; + name: string; + type: Type; +} + +export interface Property { + name: string; + type: Type; + source?: Source; + comment?: string; +} + +export interface BaseMethod { + isConstructor: boolean; + name: string; + returnComment?: string|undefined; + callPath: string; + parameters: Parameter[]; + returnType: Type; + comment?: string; +} + +export interface TypescriptMethod extends BaseMethod { + source?: Source; + isStatic?: boolean; + typeParameter?: TypeParameter; +} + +export interface SolidityMethod extends BaseMethod { + isConstant?: boolean; + isPayable?: boolean; +} + +export interface Source { + fileName: string; + line: number; +} + +export interface Parameter { + name: string; + comment: string; + isOptional: boolean; + type: Type; +} + +export interface TypeParameter { + name: string; + type: Type; +} + +export interface Type { + name: string; + typeDocType: TypeDocTypes; + value?: string; + typeArguments?: Type[]; + elementType?: ElementType; + types?: Type[]; + method?: TypescriptMethod; +} + +export interface ElementType { + name: string; + typeDocType: TypeDocTypes; +} + +export interface IndexSignature { + keyName: string; + keyType: Type; + valueName: string; +} + +export interface CustomType { + name: string; + kindString: string; + type?: Type; + method?: TypescriptMethod; + indexSignature?: IndexSignature; + defaultValue?: string; + comment?: string; + children?: CustomTypeChild[]; +} + +export interface CustomTypeChild { + name: string; + type?: Type; + defaultValue?: string; +} + +export const ZeroExJsDocSections = strEnum([ + 'introduction', + 'installation', + 'testrpc', + 'async', + 'errors', + 'versioning', + 'zeroEx', + 'exchange', + 'token', + 'tokenRegistry', + 'etherToken', + 'proxy', + 'types', +]); +export type ZeroExJsDocSections = keyof typeof ZeroExJsDocSections; + +export const SmartContractsDocSections = strEnum([ + 'Introduction', + 'Exchange', + 'TokenTransferProxy', + 'TokenRegistry', + 'ZRXToken', + 'EtherToken', +]); +export type SmartContractsDocSections = keyof typeof SmartContractsDocSections; + +export interface FAQQuestion { + prompt: string; + answer: React.ReactNode; +} +export interface FAQSection { + name: string; + questions: FAQQuestion[]; +} + +export interface S3FileObject { + Key: { + _text: string; + }; +} + +export interface MenuSubsectionsBySection { + [section: string]: string[]; +} + +export const ProviderType = strEnum([ + 'INJECTED', + 'LEDGER', +]); +export type ProviderType = keyof typeof ProviderType; + +export interface Fact { + title: string; + explanation: string; + image: string; +} + +interface LedgerGetAddressResult { + address: string; +} +interface LedgerSignResult { + v: string; + r: string; + s: string; +} +interface LedgerCommunication { + close_async: () => void; +} +export interface LedgerEthConnection { + getAddress_async: (derivationPath: string, askForDeviceConfirmation: boolean, + shouldGetChainCode: boolean) => Promise<LedgerGetAddressResult>; + signPersonalMessage_async: (derivationPath: string, messageHex: string) => Promise<LedgerSignResult>; + signTransaction_async: (derivationPath: string, txHex: string) => Promise<LedgerSignResult>; + comm: LedgerCommunication; +} +export interface SignPersonalMessageParams { + data: string; +} + +export interface LedgerWalletSubprovider { + getPath: () => string; + setPath: (path: string) => void; + setPathIndex: (pathIndex: number) => void; +} + +export interface TxParams { + nonce: string; + gasPrice?: number; + gasLimit: string; + to: string; + value?: string; + data?: string; + chainId: number; // EIP 155 chainId - mainnet: 1, ropsten: 3 +} + +export interface PublicNodeUrlsByNetworkId { + [networkId: number]: string[]; +}; + +export interface JSONRPCPayload { + params: any[]; + method: string; +} + +export interface BlogPost { + image: string; + date: string; + title: string; + description: string; + url: string; +} + +export interface TypeDefinitionByName { + [typeName: string]: CustomType; +} + +export interface Article { + section: string; + title: string; + content: string; + fileName: string; +} + +export interface ArticlesBySection { + [section: string]: Article[]; +} + +export interface DialogConfigs { + title: string; + isModal: boolean; + actions: any[]; +} + +export enum TokenVisibility { + ALL = 'ALL', + UNTRACKED = 'UNTRACKED', + TRACKED = 'TRACKED', +} + +export enum HeaderSizes { + H1 = 'h1', + H2 = 'h2', + H3 = 'h3', +} + +export interface DoxityDocObj { + [contractName: string]: DoxityContractObj; +} + +export interface DoxityContractObj { + title: string; + fileName: string; + name: string; + abiDocs: DoxityAbiDoc[]; +} + +export interface DoxityAbiDoc { + constant: boolean; + inputs: DoxityInput[]; + name: string; + outputs: DoxityOutput[]; + payable: boolean; + type: string; + details?: string; + return?: string; +} + +export interface DoxityOutput { + name: string; + type: string; +} + +export interface DoxityInput { + name: string; + type: string; + description: string; + indexed?: boolean; +} + +export interface VersionToFileName { + [version: string]: string; +} + +export enum Docs { + ZeroExJs, + SmartContracts, +} + +export interface ContractAddresses { + [version: string]: { + [network: string]: AddressByContractName; + }; +} + +export interface AddressByContractName { + [contractName: string]: string; +} + +export enum Networks { + mainnet = 'Mainnet', + kovan = 'Kovan', + ropsten = 'Ropsten', + rinkeby = 'Rinkeby', +} + +export enum AbiTypes { + Constructor = 'constructor', + Function = 'function', + Event = 'event', +} + +export enum WebsitePaths { + Portal = '/portal', + Wiki = '/wiki', + ZeroExJs = '/docs/0xjs', + Home = '/', + FAQ = '/faq', + About = '/about', + Whitepaper = '/pdfs/0x_white_paper.pdf', + SmartContracts = '/docs/contracts', +} diff --git a/packages/website/ts/utils/configs.ts b/packages/website/ts/utils/configs.ts new file mode 100644 index 000000000..63fcd27b6 --- /dev/null +++ b/packages/website/ts/utils/configs.ts @@ -0,0 +1,18 @@ +import * as _ from 'lodash'; +import {Environments} from 'ts/types'; + +const BASE_URL = window.location.origin; +const isDevelopment = _.includes(BASE_URL, 'https://0xproject.dev:3572') || + _.includes(BASE_URL, 'https://localhost:3572') || + _.includes(BASE_URL, 'https://127.0.0.1'); + +export const configs = { + BASE_URL, + ENVIRONMENT: isDevelopment ? Environments.DEVELOPMENT : Environments.PRODUCTION, + BACKEND_BASE_URL: isDevelopment ? 'https://localhost:3001' : 'https://website-api.0xproject.com', + symbolsOfMintableTokens: ['MKR', 'MLN', 'GNT', 'DGD', 'REP'], + // WARNING: ZRX & WETH MUST always be default trackedTokens + defaultTrackedTokenSymbols: ['WETH', 'ZRX'], + lastLocalStorageFillClearanceDate: '2017-11-22', + isMainnetEnabled: true, +}; diff --git a/packages/website/ts/utils/constants.ts b/packages/website/ts/utils/constants.ts new file mode 100644 index 000000000..7fc52b035 --- /dev/null +++ b/packages/website/ts/utils/constants.ts @@ -0,0 +1,273 @@ +import { + ExchangeContractErrs, + PublicNodeUrlsByNetworkId, + ZeroExJsDocSections, + SmartContractsDocSections, + Docs, + ContractAddresses, + Networks, + WebsitePaths, +} from 'ts/types'; +import BigNumber from 'bignumber.js'; + +const INFURA_API_KEY = 'T5WSC8cautR4KXyYgsRs'; + +export const constants = { + ANGELLIST_URL: 'https://angel.co/0xproject/jobs', + STAGING_DOMAIN: 'staging-0xproject.s3-website-us-east-1.amazonaws.com', + PRODUCTION_DOMAIN: '0xproject.com', + DEVELOPMENT_DOMAIN: '0xproject.dev:3572', + BIGNUMBERJS_GITHUB_URL: 'http://mikemcl.github.io/bignumber.js', + BITLY_ACCESS_TOKEN: 'ffc4c1a31e5143848fb7c523b39f91b9b213d208', + BITLY_ENDPOINT: 'https://api-ssl.bitly.com', + BLOG_URL: 'https://blog.0xproject.com/latest', + CUSTOM_BLUE: '#60a4f4', + DEFAULT_DERIVATION_PATH: `44'/60'/0'`, + ETHER_FAUCET_ENDPOINT: 'https://faucet.0xproject.com', + FEE_RECIPIENT_ADDRESS: '0x0000000000000000000000000000000000000000', + FIREFOX_U2F_ADDON: 'https://addons.mozilla.org/en-US/firefox/addon/u2f-support-add-on/', + GITHUB_URL: 'https://github.com/0xProject', + GITHUB_0X_JS_URL: 'https://github.com/0xProject/0x.js', + GITHUB_CONTRACTS_URL: 'https://github.com/0xProject/contracts', + GITHUB_WIKI_URL: 'https://github.com/0xProject/wiki', + HTTP_NO_CONTENT_STATUS_CODE: 204, + ACCEPT_DISCLAIMER_LOCAL_STORAGE_KEY: 'didAcceptPortalDisclaimer', + LINKEDIN_0X_URL: 'https://www.linkedin.com/company/0x', + LEDGER_PROVIDER_NAME: 'Ledger', + METAMASK_PROVIDER_NAME: 'Metamask', + GENESIS_ORDER_BLOCK_BY_NETWORK_ID: { + 1: 4145578, + 42: 3117574, + 50: 0, + } as {[networkId: number]: number}, + PUBLIC_PROVIDER_NAME: '0x Public', + // The order matters. We first try first node and only then fall back to others. + PUBLIC_NODE_URLS_BY_NETWORK_ID: { + [1]: [ + `https://mainnet.infura.io/${INFURA_API_KEY}`, + ], + [42]: [ + `https://kovan.infura.io/${INFURA_API_KEY}`, + ], + } as PublicNodeUrlsByNetworkId, + PARITY_SIGNER_PROVIDER_NAME: 'Parity Signer', + GENERIC_PROVIDER_NAME: 'Injected Web3', + MAKER_FEE: new BigNumber(0), + MAINNET_NAME: 'Main network', + MAINNET_NETWORK_ID: 1, + METAMASK_CHROME_STORE_URL: 'https://chrome.google.com/webstore/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn', + // tslint:disable-next-line:max-line-length + PARITY_CHROME_STORE_URL: 'https://chrome.google.com/webstore/detail/parity-ethereum-integrati/himekenlppkgeaoeddcliojfddemadig', + MIST_DOWNLOAD_URL: 'https://github.com/ethereum/mist/releases', + NULL_ADDRESS: '0x0000000000000000000000000000000000000000', + ROLLBAR_ACCESS_TOKEN: 'a6619002b51c4464928201e6ea94de65', + DOCS_SCROLL_DURATION_MS: 0, + DOCS_CONTAINER_ID: 'documentation', + HOME_SCROLL_DURATION_MS: 500, + REDDIT_URL: 'https://reddit.com/r/0xproject', + STANDARD_RELAYER_API_GITHUB: 'https://github.com/0xProject/standard-relayer-api/blob/master/README.md', + SUCCESS_STATUS: 200, + S3_0XJS_DOCUMENTATION_JSON_ROOT: 'https://s3.amazonaws.com/0xjs-docs-jsons', + S3_SMART_CONTRACTS_DOCUMENTATION_JSON_ROOT: 'https://s3.amazonaws.com/smart-contracts-docs-json', + UNAVAILABLE_STATUS: 503, + TAKER_FEE: new BigNumber(0), + TESTNET_NAME: 'Kovan', + TESTNET_NETWORK_ID: 42, + TESTRPC_NETWORK_ID: 50, + TWITTER_URL: 'https://twitter.com/0xproject', + ETH_DECIMAL_PLACES: 18, + MINT_AMOUNT: new BigNumber('100000000000000000000'), + WEB3_DOCS_URL: 'https://github.com/ethereum/wiki/wiki/JavaScript-API', + WEB3_PROVIDER_DOCS_URL: 'https://github.com/0xProject/web3-typescript-typings/blob/f5bcb96/index.d.ts#L150', + WEB3_DECODED_LOG_ENTRY_EVENT_URL: + 'https://github.com/0xProject/web3-typescript-typings/blob/f5bcb96/index.d.ts#L123', + WEB3_LOG_ENTRY_EVENT_URL: 'https://github.com/0xProject/web3-typescript-typings/blob/f5bcb96/index.d.ts#L127', + ZEROEX_CHAT_URL: 'https://chat.0xproject.com', + // Projects + ETHFINEX_URL: 'https://www.bitfinex.com/ethfinex', + RADAR_RELAY_URL: 'https://radarrelay.com', + PARADEX_URL: 'https://paradex.io', + DYDX_URL: 'https://dydx.exchange', + MELONPORT_URL: 'https://melonport.com', + DISTRICT_0X_URL: 'https://district0x.io', + DHARMA_URL: 'https://dharma.io', + LENDROID_URL: 'https://lendroid.com', + MAKER_URL: 'https://makerdao.com', + ARAGON_URL: 'https://aragon.one', + BLOCKNET_URL: 'https://blocknet.co', + OCEAN_URL: 'http://the0cean.com', + STATUS_URL: 'https://status.im', + AUGUR_URL: 'https://augur.net', + AUCTUS_URL: 'https://auctus.org', + OPEN_ANX_URL: 'https://www.openanx.org', + + iconUrlBySymbol: { + 'REP': '/images/token_icons/augur.png', + 'DGD': '/images/token_icons/digixdao.png', + 'WETH': '/images/token_icons/ether_erc20.png', + 'MLN': '/images/token_icons/melon.png', + 'GNT': '/images/token_icons/golem.png', + 'MKR': '/images/token_icons/makerdao.png', + 'ZRX': '/images/token_icons/zero_ex.png', + 'ANT': '/images/token_icons/aragon.png', + 'BNT': '/images/token_icons/bancor.png', + 'BAT': '/images/token_icons/basicattentiontoken.png', + 'CVC': '/images/token_icons/civic.png', + 'EOS': '/images/token_icons/eos.png', + 'FUN': '/images/token_icons/funfair.png', + 'GNO': '/images/token_icons/gnosis.png', + 'ICN': '/images/token_icons/iconomi.png', + 'OMG': '/images/token_icons/omisego.png', + 'SNT': '/images/token_icons/status.png', + 'STORJ': '/images/token_icons/storjcoinx.png', + 'PAY': '/images/token_icons/tenx.png', + 'QTUM': '/images/token_icons/qtum.png', + 'DNT': '/images/token_icons/district0x.png', + 'SNGLS': '/images/token_icons/singularity.png', + 'EDG': '/images/token_icons/edgeless.png', + '1ST': '/images/token_icons/firstblood.jpg', + 'WINGS': '/images/token_icons/wings.png', + 'BQX': '/images/token_icons/bitquence.png', + 'LUN': '/images/token_icons/lunyr.png', + 'RLC': '/images/token_icons/iexec.png', + 'MCO': '/images/token_icons/monaco.png', + 'ADT': '/images/token_icons/adtoken.png', + 'CFI': '/images/token_icons/cofound-it.png', + 'ROL': '/images/token_icons/etheroll.png', + 'WGNT': '/images/token_icons/golem.png', + 'MTL': '/images/token_icons/metal.png', + 'NMR': '/images/token_icons/numeraire.png', + 'SAN': '/images/token_icons/santiment.png', + 'TAAS': '/images/token_icons/taas.png', + 'TKN': '/images/token_icons/tokencard.png', + 'TRST': '/images/token_icons/trust.png', + } as {[symbol: string]: string}, + networkNameById: { + 1: Networks.mainnet, + 3: Networks.ropsten, + 4: Networks.rinkeby, + 42: Networks.kovan, + } as {[symbol: number]: string}, + networkIdByName: { + [Networks.mainnet]: 1, + [Networks.ropsten]: 3, + [Networks.rinkeby]: 4, + [Networks.kovan]: 42, + } as {[networkName: string]: number}, + // Note: This needs to be kept in sync with the types exported in index.ts. Unfortunately there is + // currently no way to extract the re-exported types from index.ts via TypeDoc :( + public0xjsTypes: [ + 'Order', + 'SignedOrder', + 'ECSignature', + 'ZeroExError', + 'EventCallback', + 'EventCallbackAsync', + 'EventCallbackSync', + 'ExchangeContractErrs', + 'ContractEvent', + 'Token', + 'ExchangeEvents', + 'IndexedFilterValues', + 'SubscriptionOpts', + 'BlockParam', + 'OrderFillOrKillRequest', + 'OrderCancellationRequest', + 'OrderFillRequest', + 'ContractEventEmitter', + 'Web3Provider', + 'ContractEventArgs', + 'LogCancelArgs', + 'LogFillArgs', + 'LogErrorContractEventArgs', + 'LogFillContractEventArgs', + 'LogCancelContractEventArgs', + 'TokenEvents', + 'ExchangeContractEventArgs', + 'TransferContractEventArgs', + 'ApprovalContractEventArgs', + 'TokenContractEventArgs', + 'ZeroExConfig', + 'TransactionReceiptWithDecodedLogs', + 'LogWithDecodedArgs', + 'DecodedLogArgs', + 'MethodOpts', + 'ValidateOrderFillableOpts', + 'OrderTransactionOpts', + 'ContractEventArg', + 'LogEvent', + 'LogEntry', + 'DecodedLogEvent', + ], + menuSmartContracts: { + introduction: [ + SmartContractsDocSections.Introduction, + ], + contracts: [ + SmartContractsDocSections.Exchange, + SmartContractsDocSections.TokenRegistry, + SmartContractsDocSections.ZRXToken, + SmartContractsDocSections.EtherToken, + SmartContractsDocSections.TokenTransferProxy, + ], + }, + menu0xjs: { + introduction: [ + ZeroExJsDocSections.introduction, + ], + install: [ + ZeroExJsDocSections.installation, + ], + topics: [ + ZeroExJsDocSections.async, + ZeroExJsDocSections.errors, + ZeroExJsDocSections.versioning, + ], + zeroEx: [ + ZeroExJsDocSections.zeroEx, + ], + contracts: [ + ZeroExJsDocSections.exchange, + ZeroExJsDocSections.token, + ZeroExJsDocSections.tokenRegistry, + ZeroExJsDocSections.etherToken, + ZeroExJsDocSections.proxy, + ], + types: [ + ZeroExJsDocSections.types, + ], + }, + menuSubsectionToVersionWhenIntroduced: { + [ZeroExJsDocSections.etherToken]: '0.7.1', + [ZeroExJsDocSections.proxy]: '0.8.0', + }, + docToPath: { + [Docs.ZeroExJs]: WebsitePaths.ZeroExJs, + [Docs.SmartContracts]: WebsitePaths.SmartContracts, + }, + contractAddresses: { + '1.0.0': { + [Networks.mainnet]: { + [SmartContractsDocSections.Exchange]: '0x12459c951127e0c374ff9105dda097662a027093', + [SmartContractsDocSections.TokenTransferProxy]: '0x8da0d80f5007ef1e431dd2127178d224e32c2ef4', + [SmartContractsDocSections.ZRXToken]: '0xe41d2489571d322189246dafa5ebde1f4699f498', + [SmartContractsDocSections.EtherToken]: '0x2956356cd2a2bf3202f771f50d3d14a367b48070', + [SmartContractsDocSections.TokenRegistry]: '0x926a74c5c36adf004c87399e65f75628b0f98d2c', + }, + [Networks.ropsten]: { + [SmartContractsDocSections.Exchange]: '0x479cc461fecd078f766ecc58533d6f69580cf3ac', + [SmartContractsDocSections.TokenTransferProxy]: '0x4e9aad8184de8833365fea970cd9149372fdf1e6', + [SmartContractsDocSections.ZRXToken]: '0xa8e9fa8f91e5ae138c74648c9c304f1c75003a8d', + [SmartContractsDocSections.EtherToken]: '0xc00fd9820cd2898cc4c054b7bf142de637ad129a', + [SmartContractsDocSections.TokenRegistry]: '0x6b1a50f0bb5a7995444bd3877b22dc89c62843ed', + }, + [Networks.kovan]: { + [SmartContractsDocSections.Exchange]: '0x90fe2af704b34e0224bf2299c838e04d4dcf1364', + [SmartContractsDocSections.TokenTransferProxy]: '0x087Eed4Bc1ee3DE49BeFbd66C662B434B15d49d4', + [SmartContractsDocSections.ZRXToken]: '0x6ff6c0ff1d68b964901f986d4c9fa3ac68346570', + [SmartContractsDocSections.EtherToken]: '0x05d090b51c40b020eab3bfcb6a2dff130df22e9c', + [SmartContractsDocSections.TokenRegistry]: '0xf18e504561f4347bea557f3d4558f559dddbae7f', + }, + }, + } as ContractAddresses, +}; diff --git a/packages/website/ts/utils/doc_utils.ts b/packages/website/ts/utils/doc_utils.ts new file mode 100644 index 000000000..ca6e0dc52 --- /dev/null +++ b/packages/website/ts/utils/doc_utils.ts @@ -0,0 +1,55 @@ +import * as _ from 'lodash'; +import findVersions = require('find-versions'); +import convert = require('xml-js'); +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import {VersionToFileName, S3FileObject, TypeDocNode, DoxityDocObj} from 'ts/types'; + +export const docUtils = { + async getVersionToFileNameAsync(s3DocJsonRoot: string): + Promise<VersionToFileName> { + const versionFileNames = await this.getVersionFileNamesAsync(s3DocJsonRoot); + const versionToFileName: VersionToFileName = {}; + _.each(versionFileNames, fileName => { + const [version] = findVersions(fileName); + versionToFileName[version] = fileName; + }); + return versionToFileName; + }, + async getVersionFileNamesAsync(s3DocJsonRoot: string): Promise<string[]> { + const response = await fetch(s3DocJsonRoot); + if (response.status !== 200) { + // TODO: Show the user an error message when the docs fail to load + const errMsg = await response.text(); + utils.consoleLog(`Failed to load JSON file list: ${response.status} ${errMsg}`); + return; + } + const responseXML = await response.text(); + const responseJSONString = convert.xml2json(responseXML, { + compact: true, + }); + const responseObj = JSON.parse(responseJSONString); + let fileObjs: S3FileObject[]; + if (_.isArray(responseObj.ListBucketResult.Contents)) { + fileObjs = responseObj.ListBucketResult.Contents as S3FileObject[]; + } else { + fileObjs = [responseObj.ListBucketResult.Contents]; + } + const versionFileNames = _.map(fileObjs, fileObj => { + return fileObj.Key._text; + }); + return versionFileNames; + }, + async getJSONDocFileAsync(fileName: string, s3DocJsonRoot: string): Promise<TypeDocNode|DoxityDocObj> { + const endpoint = `${s3DocJsonRoot}/${fileName}`; + const response = await fetch(endpoint); + if (response.status !== 200) { + // TODO: Show the user an error message when the docs fail to load + const errMsg = await response.text(); + utils.consoleLog(`Failed to load Doc JSON: ${response.status} ${errMsg}`); + return; + } + const jsonDocObj = await response.json(); + return jsonDocObj; + }, +}; diff --git a/packages/website/ts/utils/doxity_utils.ts b/packages/website/ts/utils/doxity_utils.ts new file mode 100644 index 000000000..3bab0a69d --- /dev/null +++ b/packages/website/ts/utils/doxity_utils.ts @@ -0,0 +1,162 @@ +import * as _ from 'lodash'; +import { + DoxityDocObj, + DoxityContractObj, + DoxityAbiDoc, + DoxityInput, + DocAgnosticFormat, + DocSection, + Parameter, + Property, + Type, + TypeDocTypes, + EventArg, + AbiTypes, + SolidityMethod, +} from 'ts/types'; + +export const doxityUtils = { + convertToDocAgnosticFormat(doxityDocObj: DoxityDocObj): DocAgnosticFormat { + const docAgnosticFormat: DocAgnosticFormat = {}; + _.each(doxityDocObj, (doxityContractObj: DoxityContractObj, contractName: string) => { + const doxityConstructor = _.find(doxityContractObj.abiDocs, (abiDoc: DoxityAbiDoc) => { + return abiDoc.type === AbiTypes.Constructor; + }); + const constructors = []; + if (!_.isUndefined(doxityConstructor)) { + const constructor = { + isConstructor: true, + name: doxityContractObj.name, + comment: doxityConstructor.details, + returnComment: doxityConstructor.return, + callPath: '', + parameters: this._convertParameters(doxityConstructor.inputs), + returnType: this._convertType(doxityContractObj.name), + }; + constructors.push(constructor); + } + + const doxityMethods: DoxityAbiDoc[] = _.filter<DoxityAbiDoc> + (doxityContractObj.abiDocs, (abiDoc: DoxityAbiDoc) => { + return this._isMethod(abiDoc); + }); + const methods: SolidityMethod[] = _.map<DoxityAbiDoc, SolidityMethod>(doxityMethods, + (doxityMethod: DoxityAbiDoc) => { + // We assume that none of our functions returns more then a single value + const outputIfExists = !_.isUndefined(doxityMethod.outputs) ? + doxityMethod.outputs[0] : + undefined; + const returnTypeIfExists = !_.isUndefined(outputIfExists) ? + this._convertType(outputIfExists.type) : + undefined; + // For ZRXToken, we want to convert it to zrxToken, rather then simply zRXToken + const callPath = contractName !== 'ZRXToken' ? + `${contractName[0].toLowerCase()}${contractName.slice(1)}.` : + `${contractName.slice(0, 3).toLowerCase()}${contractName.slice(3)}.`; + const method = { + isConstructor: false, + isConstant: doxityMethod.constant, + isPayable: doxityMethod.payable, + name: doxityMethod.name, + comment: doxityMethod.details, + returnComment: doxityMethod.return, + callPath, + parameters: this._convertParameters(doxityMethod.inputs), + returnType: returnTypeIfExists, + }; + return method; + }); + + const doxityProperties: DoxityAbiDoc[] = _.filter<DoxityAbiDoc> + (doxityContractObj.abiDocs, (abiDoc: DoxityAbiDoc) => { + return this._isProperty(abiDoc); + }); + const properties = _.map<DoxityAbiDoc, Property>(doxityProperties, (doxityProperty: DoxityAbiDoc) => { + // We assume that none of our functions return more then a single return value + let typeName = doxityProperty.outputs[0].type; + if (!_.isEmpty(doxityProperty.inputs)) { + // Properties never have more then a single input + typeName = `(${doxityProperty.inputs[0].type} => ${typeName})`; + } + const property = { + name: doxityProperty.name, + type: this._convertType(typeName), + comment: doxityProperty.details, + }; + return property; + }); + + const doxityEvents = _.filter( + doxityContractObj.abiDocs, (abiDoc: DoxityAbiDoc) => abiDoc.type === AbiTypes.Event, + ); + const events = _.map(doxityEvents, doxityEvent => { + const event = { + name: doxityEvent.name, + eventArgs: this._convertEventArgs(doxityEvent.inputs), + }; + return event; + }); + + const docSection: DocSection = { + comment: doxityContractObj.title, + constructors, + methods, + properties, + types: [], + events, + }; + docAgnosticFormat[contractName] = docSection; + }); + return docAgnosticFormat; + }, + _convertParameters(inputs: DoxityInput[]): Parameter[] { + const parameters = _.map(inputs, input => { + const parameter = { + name: input.name, + comment: input.description, + isOptional: false, + type: this._convertType(input.type), + }; + return parameter; + }); + return parameters; + }, + _convertType(typeName: string): Type { + const type = { + name: typeName, + typeDocType: TypeDocTypes.Intrinsic, + }; + return type; + }, + _isMethod(abiDoc: DoxityAbiDoc) { + if (abiDoc.type !== AbiTypes.Function) { + return false; + } + const hasInputs = !_.isEmpty(abiDoc.inputs); + const hasNamedOutputIfExists = !hasInputs || !_.isEmpty(abiDoc.inputs[0].name); + const isNameAllCaps = abiDoc.name === abiDoc.name.toUpperCase(); + const isMethod = hasNamedOutputIfExists && !isNameAllCaps; + return isMethod; + }, + _isProperty(abiDoc: DoxityAbiDoc) { + if (abiDoc.type !== AbiTypes.Function) { + return false; + } + const hasInputs = !_.isEmpty(abiDoc.inputs); + const hasNamedOutputIfExists = !hasInputs || !_.isEmpty(abiDoc.inputs[0].name); + const isNameAllCaps = abiDoc.name === abiDoc.name.toUpperCase(); + const isProperty = !hasNamedOutputIfExists || isNameAllCaps; + return isProperty; + }, + _convertEventArgs(inputs: DoxityInput[]): EventArg[] { + const eventArgs = _.map(inputs, input => { + const eventArg = { + isIndexed: input.indexed, + name: input.name, + type: this._convertType(input.type), + }; + return eventArg; + }); + return eventArgs; + }, +}; diff --git a/packages/website/ts/utils/error_reporter.ts b/packages/website/ts/utils/error_reporter.ts new file mode 100644 index 000000000..a9731c4d4 --- /dev/null +++ b/packages/website/ts/utils/error_reporter.ts @@ -0,0 +1,52 @@ +import {utils} from 'ts/utils/utils'; +import {constants} from 'ts/utils/constants'; +import {configs} from 'ts/utils/configs'; +import {Environments} from 'ts/types'; + +// Suggested way to include Rollbar with Webpack +// https://github.com/rollbar/rollbar.js/tree/master/examples/webpack +const rollbarConfig = { + accessToken: constants.ROLLBAR_ACCESS_TOKEN, + captureUncaught: true, + captureUnhandledRejections: true, + itemsPerMinute: 10, + maxItems: 500, + payload: { + environment: configs.ENVIRONMENT, + }, + uncaughtErrorLevel: 'error', + hostWhiteList: [constants.PRODUCTION_DOMAIN, constants.STAGING_DOMAIN], + ignoredMessages: [ + // Errors from the third-party scripts + 'Script error', + // Network errors or ad-blockers + 'TypeError: Failed to fetch', + 'Exchange has not been deployed to detected network (network/artifact mismatch)', + // Source: https://groups.google.com/a/chromium.org/forum/#!topic/chromium-discuss/7VU0_VvC7mE + 'undefined is not an object (evaluating \'__gCrWeb.autofill.extractForms\')', + // Source: http://stackoverflow.com/questions/43399818/securityerror-from-facebook-and-cross-domain-messaging + 'SecurityError (DOM Exception 18)', + ], +}; +import Rollbar = require('../../public/js/rollbar.umd.nojson.min.js'); +const rollbar = Rollbar.init(rollbarConfig); + +export const errorReporter = { + reportAsync(err: Error): Promise<any> { + if (configs.ENVIRONMENT === Environments.DEVELOPMENT) { + return; // Let's not log development errors to rollbar + } + + return new Promise((resolve, reject) => { + rollbar.error(err, (rollbarErr: Error) => { + if (rollbarErr) { + utils.consoleLog(`Error reporting to rollbar, ignoring: ${rollbarErr}`); + // We never want to reject and cause the app to throw because of rollbar + resolve(); + } else { + resolve(); + } + }); + }); + }, +}; diff --git a/packages/website/ts/utils/typedoc_utils.ts b/packages/website/ts/utils/typedoc_utils.ts new file mode 100644 index 000000000..b3d0f7d90 --- /dev/null +++ b/packages/website/ts/utils/typedoc_utils.ts @@ -0,0 +1,356 @@ +import * as _ from 'lodash'; +import compareVersions = require('compare-versions'); +import {constants} from 'ts/utils/constants'; +import {utils} from 'ts/utils/utils'; +import { + TypeDocNode, + KindString, + ZeroExJsDocSections, + MenuSubsectionsBySection, + TypeDocType, + Type, + DocAgnosticFormat, + DocSection, + TypescriptMethod, + Parameter, + Property, + CustomType, + IndexSignature, + CustomTypeChild, + TypeParameter, + TypeDocTypes, +} from 'ts/types'; + +const TYPES_MODULE_PATH = '"src/types"'; + +export const sectionNameToPossibleModulePaths: {[name: string]: string[]} = { + [ZeroExJsDocSections.zeroEx]: ['"src/0x"'], + [ZeroExJsDocSections.exchange]: ['"src/contract_wrappers/exchange_wrapper"'], + [ZeroExJsDocSections.tokenRegistry]: ['"src/contract_wrappers/token_registry_wrapper"'], + [ZeroExJsDocSections.token]: ['"src/contract_wrappers/token_wrapper"'], + [ZeroExJsDocSections.etherToken]: ['"src/contract_wrappers/ether_token_wrapper"'], + [ZeroExJsDocSections.proxy]: [ + '"src/contract_wrappers/proxy_wrapper"', + '"src/contract_wrappers/token_transfer_proxy_wrapper"', + ], + [ZeroExJsDocSections.types]: [TYPES_MODULE_PATH], +}; + +export const typeDocUtils = { + isType(entity: TypeDocNode): boolean { + return entity.kindString === KindString.Interface || + entity.kindString === KindString.Function || + entity.kindString === KindString['Type alias'] || + entity.kindString === KindString.Variable || + entity.kindString === KindString.Enumeration; + }, + isMethod(entity: TypeDocNode): boolean { + return entity.kindString === KindString.Method; + }, + isConstructor(entity: TypeDocNode): boolean { + return entity.kindString === KindString.Constructor; + }, + isProperty(entity: TypeDocNode): boolean { + return entity.kindString === KindString.Property; + }, + isPrivateOrProtectedProperty(propertyName: string): boolean { + return _.startsWith(propertyName, '_'); + }, + isPublicType(typeName: string): boolean { + return _.includes(constants.public0xjsTypes, typeName); + }, + getModuleDefinitionBySectionNameIfExists(versionDocObj: TypeDocNode, sectionName: string): + TypeDocNode|undefined { + const possibleModulePathNames = sectionNameToPossibleModulePaths[sectionName]; + const modules = versionDocObj.children; + for (const mod of modules) { + if (_.includes(possibleModulePathNames, mod.name)) { + const moduleWithName = mod; + return moduleWithName; + } + } + return undefined; + }, + getMenuSubsectionsBySection(docAgnosticFormat?: DocAgnosticFormat): MenuSubsectionsBySection { + const menuSubsectionsBySection = {} as MenuSubsectionsBySection; + if (_.isUndefined(docAgnosticFormat)) { + return menuSubsectionsBySection; + } + + const docSections = _.keys(ZeroExJsDocSections); + _.each(docSections, sectionName => { + const docSection = docAgnosticFormat[sectionName]; + if (_.isUndefined(docSection)) { + return; // no-op + } + + if (sectionName === ZeroExJsDocSections.types) { + const typeNames = _.map(docSection.types, t => t.name); + menuSubsectionsBySection[sectionName] = typeNames; + } else { + const methodNames = _.map(docSection.methods, m => m.name); + menuSubsectionsBySection[sectionName] = methodNames; + } + }); + return menuSubsectionsBySection; + }, + getFinal0xjsMenu(selectedVersion: string): {[section: string]: string[]} { + const finalMenu = _.cloneDeep(constants.menu0xjs); + finalMenu.contracts = _.filter(finalMenu.contracts, (contractName: string) => { + const versionIntroducedIfExists = constants.menuSubsectionToVersionWhenIntroduced[contractName]; + if (!_.isUndefined(versionIntroducedIfExists)) { + const existsInSelectedVersion = compareVersions(selectedVersion, + versionIntroducedIfExists) >= 0; + return existsInSelectedVersion; + } else { + return true; + } + }); + return finalMenu; + }, + convertToDocAgnosticFormat(typeDocJson: TypeDocNode): DocAgnosticFormat { + const subMenus = _.values(constants.menu0xjs); + const orderedSectionNames = _.flatten(subMenus); + const docAgnosticFormat: DocAgnosticFormat = {}; + _.each(orderedSectionNames, sectionName => { + const packageDefinitionIfExists = typeDocUtils.getModuleDefinitionBySectionNameIfExists( + typeDocJson, sectionName, + ); + if (_.isUndefined(packageDefinitionIfExists)) { + return; // no-op + } + + // Since the `types.ts` file is the only file that does not export a module/class but + // instead has each type export itself, we do not need to go down two levels of nesting + // for it. + let entities; + let packageComment = ''; + if (sectionName === ZeroExJsDocSections.types) { + entities = packageDefinitionIfExists.children; + } else { + entities = packageDefinitionIfExists.children[0].children; + const commentObj = packageDefinitionIfExists.children[0].comment; + packageComment = !_.isUndefined(commentObj) ? commentObj.shortText : packageComment; + } + + const docSection = typeDocUtils._convertEntitiesToDocSection(entities, sectionName); + docSection.comment = packageComment; + docAgnosticFormat[sectionName] = docSection; + }); + return docAgnosticFormat; + }, + _convertEntitiesToDocSection(entities: TypeDocNode[], sectionName: string) { + const docSection: DocSection = { + comment: '', + constructors: [], + methods: [], + properties: [], + types: [], + }; + + let isConstructor; + _.each(entities, entity => { + switch (entity.kindString) { + case KindString.Constructor: + isConstructor = true; + const constructor = typeDocUtils._convertMethod(entity, isConstructor, sectionName); + docSection.constructors.push(constructor); + break; + + case KindString.Method: + if (entity.flags.isPublic) { + isConstructor = false; + const method = typeDocUtils._convertMethod(entity, isConstructor, sectionName); + docSection.methods.push(method); + } + break; + + case KindString.Property: + if (!typeDocUtils.isPrivateOrProtectedProperty(entity.name)) { + const property = typeDocUtils._convertProperty(entity, sectionName); + docSection.properties.push(property); + } + break; + + case KindString.Interface: + case KindString.Function: + case KindString.Variable: + case KindString.Enumeration: + case KindString['Type alias']: + if (typeDocUtils.isPublicType(entity.name)) { + const customType = typeDocUtils._convertCustomType(entity, sectionName); + docSection.types.push(customType); + } + break; + + default: + throw utils.spawnSwitchErr('kindString', entity.kindString); + } + }); + return docSection; + }, + _convertCustomType(entity: TypeDocNode, sectionName: string): CustomType { + const typeIfExists = !_.isUndefined(entity.type) ? + typeDocUtils._convertType(entity.type, sectionName) : + undefined; + const isConstructor = false; + const methodIfExists = !_.isUndefined(entity.declaration) ? + typeDocUtils._convertMethod(entity.declaration, isConstructor, sectionName) : + undefined; + const indexSignatureIfExists = !_.isUndefined(entity.indexSignature) ? + typeDocUtils._convertIndexSignature(entity.indexSignature[0], sectionName) : + undefined; + const commentIfExists = !_.isUndefined(entity.comment) && !_.isUndefined(entity.comment.shortText) ? + entity.comment.shortText : + undefined; + + const childrenIfExist = !_.isUndefined(entity.children) ? + _.map(entity.children, (child: TypeDocNode) => { + const childTypeIfExists = !_.isUndefined(child.type) ? + typeDocUtils._convertType(child.type, sectionName) : + undefined; + const c: CustomTypeChild = { + name: child.name, + type: childTypeIfExists, + defaultValue: child.defaultValue, + }; + return c; + }) : + undefined; + + const customType = { + name: entity.name, + kindString: entity.kindString, + type: typeIfExists, + method: methodIfExists, + indexSignature: indexSignatureIfExists, + defaultValue: entity.defaultValue, + comment: commentIfExists, + children: childrenIfExist, + }; + return customType; + }, + _convertIndexSignature(entity: TypeDocNode, sectionName: string): IndexSignature { + const key = entity.parameters[0]; + const indexSignature = { + keyName: key.name, + keyType: typeDocUtils._convertType(key.type, sectionName), + valueName: entity.type.name, + }; + return indexSignature; + }, + _convertProperty(entity: TypeDocNode, sectionName: string): Property { + const source = entity.sources[0]; + const commentIfExists = !_.isUndefined(entity.comment) ? entity.comment.shortText : undefined; + const property = { + name: entity.name, + type: typeDocUtils._convertType(entity.type, sectionName), + source: { + fileName: source.fileName, + line: source.line, + }, + comment: commentIfExists, + }; + return property; + }, + _convertMethod(entity: TypeDocNode, isConstructor: boolean, sectionName: string): TypescriptMethod { + const signature = entity.signatures[0]; + const source = entity.sources[0]; + const hasComment = !_.isUndefined(signature.comment); + const isStatic = _.isUndefined(entity.flags.isStatic) ? false : entity.flags.isStatic; + + const topLevelInterface = isStatic ? 'ZeroEx.' : 'zeroEx.'; + // HACK: we use the fact that the sectionName is the same as the property name at the top-level + // of the public interface. In the future, we shouldn't use this hack but rather get it from the JSON. + let callPath = (sectionName !== ZeroExJsDocSections.zeroEx) ? + `${topLevelInterface}${sectionName}.` : + topLevelInterface; + callPath = isConstructor ? '' : callPath; + + const parameters = _.map(signature.parameters, param => { + return typeDocUtils._convertParameter(param, sectionName); + }); + const returnType = typeDocUtils._convertType(signature.type, sectionName); + const typeParameter = _.isUndefined(signature.typeParameter) ? + undefined : + typeDocUtils._convertTypeParameter(signature.typeParameter[0], sectionName); + + const method = { + isConstructor, + isStatic, + name: signature.name, + comment: hasComment ? signature.comment.shortText : undefined, + returnComment: hasComment && signature.comment.returns ? signature.comment.returns : undefined, + source: { + fileName: source.fileName, + line: source.line, + }, + callPath, + parameters, + returnType, + typeParameter, + }; + return method; + }, + _convertTypeParameter(entity: TypeDocNode, sectionName: string): TypeParameter { + const type = typeDocUtils._convertType(entity.type, sectionName); + const parameter = { + name: entity.name, + type, + }; + return parameter; + }, + _convertParameter(entity: TypeDocNode, sectionName: string): Parameter { + let comment = '<No comment>'; + if (entity.comment && entity.comment.shortText) { + comment = entity.comment.shortText; + } else if (entity.comment && entity.comment.text) { + comment = entity.comment.text; + } + + const isOptional = !_.isUndefined(entity.flags.isOptional) ? + entity.flags.isOptional : + false; + + const type = typeDocUtils._convertType(entity.type, sectionName); + + const parameter = { + name: entity.name, + comment, + isOptional, + type, + }; + return parameter; + }, + _convertType(entity: TypeDocType, sectionName: string): Type { + const typeArguments = _.map(entity.typeArguments, typeArgument => { + return typeDocUtils._convertType(typeArgument, sectionName); + }); + const types = _.map(entity.types, t => { + return typeDocUtils._convertType(t, sectionName); + }); + + const isConstructor = false; + const methodIfExists = !_.isUndefined(entity.declaration) ? + typeDocUtils._convertMethod(entity.declaration, isConstructor, sectionName) : + undefined; + + const elementTypeIfExists = !_.isUndefined(entity.elementType) ? + { + name: entity.elementType.name, + typeDocType: entity.elementType.type, + } : + undefined; + + const type = { + name: entity.name, + value: entity.value, + typeDocType: entity.type, + typeArguments, + elementType: elementTypeIfExists, + types, + method: methodIfExists, + }; + return type; + }, +}; diff --git a/packages/website/ts/utils/utils.ts b/packages/website/ts/utils/utils.ts new file mode 100644 index 000000000..eb4c5be3a --- /dev/null +++ b/packages/website/ts/utils/utils.ts @@ -0,0 +1,215 @@ +import * as _ from 'lodash'; +import { + SideToAssetToken, + SignatureData, + Order, + Side, + TokenByAddress, + OrderParty, + ScreenWidths, + EtherscanLinkSuffixes, + Token, + Networks, +} from 'ts/types'; +import * as moment from 'moment'; +import isMobile = require('is-mobile'); +import * as u2f from 'ts/vendor/u2f_api'; +import deepEqual = require('deep-equal'); +import ethUtil = require('ethereumjs-util'); +import BigNumber from 'bignumber.js'; +import {constants} from 'ts/utils/constants'; + +const LG_MIN_EM = 64; +const MD_MIN_EM = 52; + +export const utils = { + assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } + }, + spawnSwitchErr(name: string, value: any) { + return new Error(`Unexpected switch value: ${value} encountered for ${name}`); + }, + isNumeric(n: string) { + return !isNaN(parseFloat(n)) && isFinite(Number(n)); + }, + // This default unix timestamp is used for orders where the user does not specify an expiry date. + // It is a fixed constant so that both the redux store's INITIAL_STATE and components can check for + // whether a user has set an expiry date or not. It is set unrealistically high so as not to collide + // with actual values a user would select. + initialOrderExpiryUnixTimestampSec(): BigNumber { + const m = moment('2050-01-01'); + return new BigNumber(m.unix()); + }, + convertToUnixTimestampSeconds(date: moment.Moment, time?: moment.Moment): BigNumber { + const finalMoment = date; + if (!_.isUndefined(time)) { + finalMoment.hours(time.hours()); + finalMoment.minutes(time.minutes()); + } + return new BigNumber(finalMoment.unix()); + }, + convertToMomentFromUnixTimestamp(unixTimestampSec: BigNumber): moment.Moment { + return moment.unix(unixTimestampSec.toNumber()); + }, + convertToReadableDateTimeFromUnixTimestamp(unixTimestampSec: BigNumber): string { + const m = this.convertToMomentFromUnixTimestamp(unixTimestampSec); + const formattedDate: string = m.format('h:MMa MMMM D YYYY'); + return formattedDate; + }, + generateOrder(networkId: number, exchangeContract: string, sideToAssetToken: SideToAssetToken, + orderExpiryTimestamp: BigNumber, orderTakerAddress: string, orderMakerAddress: string, + makerFee: BigNumber, takerFee: BigNumber, feeRecipient: string, + signatureData: SignatureData, tokenByAddress: TokenByAddress, orderSalt: BigNumber): Order { + const makerToken = tokenByAddress[sideToAssetToken[Side.deposit].address]; + const takerToken = tokenByAddress[sideToAssetToken[Side.receive].address]; + const order = { + maker: { + address: orderMakerAddress, + token: { + name: makerToken.name, + symbol: makerToken.symbol, + decimals: makerToken.decimals, + address: makerToken.address, + }, + amount: sideToAssetToken[Side.deposit].amount.toString(), + feeAmount: makerFee.toString(), + }, + taker: { + address: orderTakerAddress, + token: { + name: takerToken.name, + symbol: takerToken.symbol, + decimals: takerToken.decimals, + address: takerToken.address, + }, + amount: sideToAssetToken[Side.receive].amount.toString(), + feeAmount: takerFee.toString(), + }, + expiration: orderExpiryTimestamp.toString(), + feeRecipient, + salt: orderSalt.toString(), + signature: signatureData, + exchangeContract, + networkId, + }; + return order; + }, + consoleLog(message: string) { + /* tslint:disable */ + console.log(message); + /* tslint:enable */ + }, + sleepAsync(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + }, + deepEqual(actual: any, expected: any, opts?: {strict: boolean}) { + return deepEqual(actual, expected, opts); + }, + getColSize(items: number) { + const bassCssGridSize = 12; // Source: http://basscss.com/#basscss-grid + const colSize = 12 / items; + if (!_.isInteger(colSize)) { + throw new Error('Number of cols must be divisible by 12'); + } + return colSize; + }, + getScreenWidth() { + const documentEl = document.documentElement; + const body = document.getElementsByTagName('body')[0]; + const widthInPx = window.innerWidth || documentEl.clientWidth || body.clientWidth; + const bodyStyles: any = window.getComputedStyle(document.querySelector('body')); + const widthInEm = widthInPx / parseFloat(bodyStyles['font-size']); + + // This logic mirrors the CSS media queries in BassCSS for the `lg-`, `md-` and `sm-` CSS + // class prefixes. Do not edit these. + if (widthInEm > LG_MIN_EM) { + return ScreenWidths.LG; + } else if (widthInEm > MD_MIN_EM) { + return ScreenWidths.MD; + } else { + return ScreenWidths.SM; + } + }, + isUserOnMobile(): boolean { + const isUserOnMobile = isMobile(); + return isUserOnMobile; + }, + getEtherScanLinkIfExists(addressOrTxHash: string, networkId: number, suffix: EtherscanLinkSuffixes): string { + const networkName = constants.networkNameById[networkId]; + if (_.isUndefined(networkName)) { + return undefined; + } + const etherScanPrefix = networkName === Networks.mainnet ? '' : `${networkName.toLowerCase()}.`; + return `https://${etherScanPrefix}etherscan.io/${suffix}/${addressOrTxHash}`; + }, + setUrlHash(anchorId: string) { + window.location.hash = anchorId; + }, + async isU2FSupportedAsync(): Promise<boolean> { + const w = (window as any); + return new Promise((resolve: (isSupported: boolean) => void) => { + if (w.u2f && !w.u2f.getApiVersion) { + // u2f object was found (Firefox with extension) + resolve(true); + } else { + // u2f object was not found. Using Google polyfill + // HACK: u2f.getApiVersion will simply not return a version if the + // U2F call fails for any reason. Because of this, we set a hard 3sec + // timeout to the request on our end. + const getApiVersionTimeoutMs = 3000; + const intervalId = setTimeout(() => { + resolve(false); + }, getApiVersionTimeoutMs); + u2f.getApiVersion((version: number) => { + clearTimeout(intervalId); + resolve(true); + }); + } + }); + }, + // This checks the error message returned from an injected Web3 instance on the page + // after a user was prompted to sign a message or send a transaction and decided to + // reject the request. + didUserDenyWeb3Request(errMsg: string) { + const metamaskDenialErrMsg = 'User denied message'; + const paritySignerDenialErrMsg = 'Request has been rejected'; + const ledgerDenialErrMsg = 'Invalid status 6985'; + const isUserDeniedErrMsg = _.includes(errMsg, metamaskDenialErrMsg) || + _.includes(errMsg, paritySignerDenialErrMsg) || + _.includes(errMsg, ledgerDenialErrMsg); + return isUserDeniedErrMsg; + }, + getCurrentEnvironment() { + switch (location.host) { + case constants.DEVELOPMENT_DOMAIN: + return 'development'; + case constants.STAGING_DOMAIN: + return 'staging'; + case constants.PRODUCTION_DOMAIN: + return 'production'; + default: + return 'production'; + } + }, + getIdFromName(name: string) { + const id = name.replace(/ /g, '-'); + return id; + }, + getAddressBeginAndEnd(address: string): string { + const truncatedAddress = `${address.substring(0, 6)}...${address.substr(-4)}`; // 0x3d5a...b287 + return truncatedAddress; + }, + hasUniqueNameAndSymbol(tokens: Token[], token: Token) { + if (token.isRegistered) { + return true; // Since it's registered, it is the canonical token + } + const registeredTokens = _.filter(tokens, t => t.isRegistered); + const tokenWithSameNameIfExists = _.find(registeredTokens, {name: token.name}); + const isUniqueName = _.isUndefined(tokenWithSameNameIfExists); + const tokenWithSameSymbolIfExists = _.find(registeredTokens, {name: token.symbol}); + const isUniqueSymbol = _.isUndefined(tokenWithSameSymbolIfExists); + return isUniqueName && isUniqueSymbol; + }, +}; diff --git a/packages/website/ts/vendor/u2f_api.js b/packages/website/ts/vendor/u2f_api.js new file mode 100644 index 000000000..3b538d817 --- /dev/null +++ b/packages/website/ts/vendor/u2f_api.js @@ -0,0 +1,760 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * Require integration + */ +if (typeof module != "undefined") { + module.exports = u2f; +} + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array<u2f.Transport>} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +/* +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; +*/ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.ledger.android.u2f.bridge.AUTHENTICATE'; + + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.<u2f.Response>} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + //console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + //console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; diff --git a/packages/website/ts/web3_wrapper.ts b/packages/website/ts/web3_wrapper.ts new file mode 100644 index 000000000..24279f5d2 --- /dev/null +++ b/packages/website/ts/web3_wrapper.ts @@ -0,0 +1,146 @@ +import * as _ from 'lodash'; +import Web3 = require('web3'); +import BigNumber from 'bignumber.js'; +import promisify = require('es6-promisify'); +import {Dispatcher} from 'ts/redux/dispatcher'; + +export class Web3Wrapper { + private dispatcher: Dispatcher; + private web3: Web3; + private prevNetworkId: number; + private shouldPollUserAddress: boolean; + private watchNetworkAndBalanceIntervalId: number; + private prevUserEtherBalanceInEth: BigNumber; + private prevUserAddress: string; + constructor(dispatcher: Dispatcher, provider: Web3.Provider, networkIdIfExists: number, + shouldPollUserAddress: boolean) { + this.dispatcher = dispatcher; + this.prevNetworkId = networkIdIfExists; + this.shouldPollUserAddress = shouldPollUserAddress; + + this.web3 = new Web3(); + this.web3.setProvider(provider); + + this.startEmittingNetworkConnectionAndUserBalanceStateAsync(); + } + public isAddress(address: string) { + return this.web3.isAddress(address); + } + public async getAccountsAsync(): Promise<string[]> { + const addresses = await promisify(this.web3.eth.getAccounts)(); + return addresses; + } + public async getFirstAccountIfExistsAsync() { + const addresses = await this.getAccountsAsync(); + if (_.isEmpty(addresses)) { + return ''; + } + return (addresses as string[])[0]; + } + public async getNodeVersionAsync() { + const nodeVersion = await promisify(this.web3.version.getNode)(); + return nodeVersion; + } + public getProviderObj() { + return this.web3.currentProvider; + } + public async getNetworkIdIfExists() { + try { + const networkId = await this.getNetworkAsync(); + return Number(networkId); + } catch (err) { + return undefined; + } + } + public async getBalanceInEthAsync(owner: string): Promise<BigNumber> { + const balanceInWei: BigNumber = await promisify(this.web3.eth.getBalance)(owner); + const balanceEthOldBigNumber = this.web3.fromWei(balanceInWei, 'ether'); + const balanceEth = new BigNumber(balanceEthOldBigNumber); + return balanceEth; + } + public async doesContractExistAtAddressAsync(address: string): Promise<boolean> { + const code = await promisify(this.web3.eth.getCode)(address); + // Regex matches 0x0, 0x00, 0x in order to accomodate poorly implemented clients + const zeroHexAddressRegex = /^0[xX][0]*$/; + const didFindCode = _.isNull(code.match(zeroHexAddressRegex)); + return didFindCode; + } + public async signTransactionAsync(address: string, message: string): Promise<string> { + const signData = await promisify(this.web3.eth.sign)(address, message); + return signData; + } + public async getBlockTimestampAsync(blockHash: string): Promise<number> { + const {timestamp} = await promisify(this.web3.eth.getBlock)(blockHash); + return timestamp; + } + public destroy() { + this.stopEmittingNetworkConnectionAndUserBalanceStateAsync(); + // HACK: stop() is only available on providerEngine instances + const provider = this.web3.currentProvider; + if (!_.isUndefined((provider as any).stop)) { + (provider as any).stop(); + } + } + // This should only be called from the LedgerConfigDialog + public updatePrevUserAddress(userAddress: string) { + this.prevUserAddress = userAddress; + } + private async getNetworkAsync() { + const networkId = await promisify(this.web3.version.getNetwork)(); + return networkId; + } + private async startEmittingNetworkConnectionAndUserBalanceStateAsync() { + if (!_.isUndefined(this.watchNetworkAndBalanceIntervalId)) { + return; // we are already emitting the state + } + + let prevNodeVersion: string; + this.prevUserEtherBalanceInEth = new BigNumber(0); + this.dispatcher.updateNetworkId(this.prevNetworkId); + this.watchNetworkAndBalanceIntervalId = window.setInterval(async () => { + // Check for network state changes + const currentNetworkId = await this.getNetworkIdIfExists(); + if (currentNetworkId !== this.prevNetworkId) { + this.prevNetworkId = currentNetworkId; + this.dispatcher.updateNetworkId(currentNetworkId); + } + + // Check for node version changes + const currentNodeVersion = await this.getNodeVersionAsync(); + if (currentNodeVersion !== prevNodeVersion) { + prevNodeVersion = currentNodeVersion; + this.dispatcher.updateNodeVersion(currentNodeVersion); + } + + if (this.shouldPollUserAddress) { + const userAddressIfExists = await this.getFirstAccountIfExistsAsync(); + // Update makerAddress on network change + if (this.prevUserAddress !== userAddressIfExists) { + this.prevUserAddress = userAddressIfExists; + this.dispatcher.updateUserAddress(userAddressIfExists); + } + + // Check for user ether balance changes + if (userAddressIfExists !== '') { + await this.updateUserEtherBalanceAsync(userAddressIfExists); + } + } else { + // This logic is primarily for the Ledger, since we don't regularly poll for the address + // we simply update the balance for the last fetched address. + if (!_.isEmpty(this.prevUserAddress)) { + await this.updateUserEtherBalanceAsync(this.prevUserAddress); + } + } + }, 5000); + } + private async updateUserEtherBalanceAsync(userAddress: string) { + const balance = await this.getBalanceInEthAsync(userAddress); + if (!balance.eq(this.prevUserEtherBalanceInEth)) { + this.prevUserEtherBalanceInEth = balance; + this.dispatcher.updateUserEtherBalance(balance); + } + } + private stopEmittingNetworkConnectionAndUserBalanceStateAsync() { + clearInterval(this.watchNetworkAndBalanceIntervalId); + } +} |