diff options
-rw-r--r-- | packages/website/package.json | 2 | ||||
-rw-r--r-- | packages/website/ts/blockchain.ts | 16 | ||||
-rw-r--r-- | packages/website/ts/components/wallet/wallet.tsx | 105 | ||||
-rw-r--r-- | packages/website/ts/pages/wiki/wiki.tsx | 53 | ||||
-rw-r--r-- | packages/website/ts/types.ts | 16 | ||||
-rw-r--r-- | packages/website/ts/utils/backend_client.ts | 59 | ||||
-rw-r--r-- | yarn.lock | 13 |
7 files changed, 188 insertions, 76 deletions
diff --git a/packages/website/package.json b/packages/website/package.json index 79d4c95cc..169231fac 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -32,6 +32,7 @@ "lodash": "^4.17.4", "material-ui": "^0.17.1", "moment": "2.21.0", + "query-string": "^6.0.0", "react": "15.6.1", "react-copy-to-clipboard": "^4.2.3", "react-document-title": "^2.0.3", @@ -58,6 +59,7 @@ "@types/lodash": "4.14.104", "@types/material-ui": "0.18.0", "@types/node": "^8.0.53", + "@types/query-string": "^5.1.0", "@types/react": "^16.0.34", "@types/react-copy-to-clipboard": "^4.2.0", "@types/react-dom": "^16.0.3", diff --git a/packages/website/ts/blockchain.ts b/packages/website/ts/blockchain.ts index 3edc00644..e90dfa747 100644 --- a/packages/website/ts/blockchain.ts +++ b/packages/website/ts/blockchain.ts @@ -47,6 +47,7 @@ import { Token, TokenByAddress, } from 'ts/types'; +import { backendClient } from 'ts/utils/backend_client'; import { configs } from 'ts/utils/configs'; import { constants } from 'ts/utils/constants'; import { errorReporter } from 'ts/utils/error_reporter'; @@ -854,14 +855,13 @@ export class Blockchain { } } private async _updateDefaultGasPriceAsync() { - const endpoint = `${configs.BACKEND_BASE_URL}/eth_gas_station`; - const response = await fetch(endpoint); - if (response.status !== 200) { - return; // noop and we keep hard-coded default + try { + const gasInfo = await backendClient.getGasInfoAsync(); + const gasPriceInGwei = new BigNumber(gasInfo.average / 10); + const gasPriceInWei = gasPriceInGwei.mul(1000000000); + this._defaultGasPrice = gasPriceInWei; + } catch (err) { + return; } - const gasInfo = await response.json(); - const gasPriceInGwei = new BigNumber(gasInfo.average / 10); - const gasPriceInWei = gasPriceInGwei.mul(1000000000); - this._defaultGasPrice = gasPriceInWei; } } // tslint:disable:max-file-line-count diff --git a/packages/website/ts/components/wallet/wallet.tsx b/packages/website/ts/components/wallet/wallet.tsx index d3dc8e3dd..d1ae38550 100644 --- a/packages/website/ts/components/wallet/wallet.tsx +++ b/packages/website/ts/components/wallet/wallet.tsx @@ -28,6 +28,7 @@ import { Dispatcher } from 'ts/redux/dispatcher'; import { BalanceErrs, BlockchainErrs, + ItemByAddress, ProviderType, Side, Token, @@ -35,6 +36,7 @@ import { TokenState, TokenStateByAddress, } from 'ts/types'; +import { backendClient } from 'ts/utils/backend_client'; import { constants } from 'ts/utils/constants'; import { utils } from 'ts/utils/utils'; import { styles as walletItemStyles } from 'ts/utils/wallet_item_styles'; @@ -125,13 +127,15 @@ const HEADER_ITEM_KEY = 'HEADER'; const FOOTER_ITEM_KEY = 'FOOTER'; const DISCONNECTED_ITEM_KEY = 'DISCONNECTED'; const ETHER_ITEM_KEY = 'ETHER'; +const USD_DECIMAL_PLACES = 2; export class Wallet extends React.Component<WalletProps, WalletState> { private _isUnmounted: boolean; constructor(props: WalletProps) { super(props); this._isUnmounted = false; - const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress(props.trackedTokens); + const trackedTokenAddresses = _.map(props.trackedTokens, token => token.address); + const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress(trackedTokenAddresses); this.state = { trackedTokenStateByAddress: initialTrackedTokenStateByAddress, wrappedEtherDirection: undefined, @@ -161,13 +165,8 @@ export class Wallet extends React.Component<WalletProps, WalletState> { // Add placeholder entry for this token to the state, since fetching the // balance/allowance is asynchronous const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress; - _.each(newTokenAddresses, (tokenAddress: string) => { - trackedTokenStateByAddress[tokenAddress] = { - balance: new BigNumber(0), - allowance: new BigNumber(0), - isLoaded: false, - }; - }); + const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress(newTokenAddresses); + _.assign(trackedTokenStateByAddress, initialTrackedTokenStateByAddress); this.setState({ trackedTokenStateByAddress, }); @@ -241,6 +240,13 @@ export class Wallet extends React.Component<WalletProps, WalletState> { constants.DECIMAL_PLACES_ETH, ETHER_SYMBOL, ); + const etherToken = this._getEthToken(); + const etherPrice = this.state.trackedTokenStateByAddress[etherToken.address].price; + const secondaryText = this._renderValue( + this.props.userEtherBalanceInWei, + constants.DECIMAL_PLACES_ETH, + etherPrice, + ); const accessoryItemConfig = { wrappedEtherDirection: Side.Deposit, }; @@ -250,11 +256,11 @@ export class Wallet extends React.Component<WalletProps, WalletState> { const style = isInWrappedEtherState ? { ...walletItemStyles.focusedItem, ...styles.paddedItem } : { ...styles.tokenItem, ...styles.borderedItem, ...styles.paddedItem }; - const etherToken = this._getEthToken(); return ( <div key={ETHER_ITEM_KEY}> <ListItem primaryText={primaryText} + secondaryText={secondaryText} leftIcon={<img style={{ width: ICON_DIMENSION, height: ICON_DIMENSION }} src={ETHER_ICON_PATH} />} rightAvatar={this._renderAccessoryItems(accessoryItemConfig)} disableTouchRipple={true} @@ -294,7 +300,8 @@ export class Wallet extends React.Component<WalletProps, WalletState> { this.props.networkId, EtherscanLinkSuffixes.Address, ); - const amount = this._renderAmount(tokenState.balance, token.decimals, token.symbol); + const primaryText = this._renderAmount(tokenState.balance, token.decimals, token.symbol); + const secondaryText = this._renderValue(tokenState.balance, token.decimals, tokenState.price); const wrappedEtherDirection = token.symbol === ETHER_TOKEN_SYMBOL ? Side.Receive : undefined; const accessoryItemConfig: AccessoryItemConfig = { wrappedEtherDirection, @@ -313,7 +320,8 @@ export class Wallet extends React.Component<WalletProps, WalletState> { return ( <div key={token.address}> <ListItem - primaryText={amount} + primaryText={primaryText} + secondaryText={secondaryText} leftIcon={this._renderTokenIcon(token, tokenLink)} rightAvatar={this._renderAccessoryItems(accessoryItemConfig)} disableTouchRipple={true} @@ -374,6 +382,16 @@ export class Wallet extends React.Component<WalletProps, WalletState> { const result = `${formattedAmount} ${symbol}`; return <div style={styles.amountLabel}>{result}</div>; } + private _renderValue(amount: BigNumber, decimals: number, price?: BigNumber) { + if (_.isUndefined(price)) { + return null; + } + const unitAmount = ZeroEx.toUnitAmount(amount, decimals); + const value = unitAmount.mul(price); + const formattedAmount = value.toFixed(USD_DECIMAL_PLACES); + const result = `$${formattedAmount}`; + return result; + } private _renderTokenIcon(token: Token, tokenLink?: string) { const tooltipId = `tooltip-${token.address}`; const tokenIcon = <TokenIcon token={token} diameter={ICON_DIMENSION} />; @@ -422,10 +440,10 @@ export class Wallet extends React.Component<WalletProps, WalletState> { /> ); } - private _getInitialTrackedTokenStateByAddress(trackedTokens: Token[]) { + private _getInitialTrackedTokenStateByAddress(tokenAddresses: string[]) { const trackedTokenStateByAddress: TokenStateByAddress = {}; - _.each(trackedTokens, token => { - trackedTokenStateByAddress[token.address] = { + _.each(tokenAddresses, tokenAddress => { + trackedTokenStateByAddress[tokenAddress] = { balance: new BigNumber(0), allowance: new BigNumber(0), isLoaded: false, @@ -434,19 +452,32 @@ export class Wallet extends React.Component<WalletProps, WalletState> { return trackedTokenStateByAddress; } private async _fetchBalancesAndAllowancesAsync(tokenAddresses: string[]) { - const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress; + const balanceAndAllowanceTupleByAddress: ItemByAddress<BigNumber[]> = {}; const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress; for (const tokenAddress of tokenAddresses) { - const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync( + const balanceAndAllowanceTuple = await this.props.blockchain.getTokenBalanceAndAllowanceAsync( userAddressIfExists, tokenAddress, ); - trackedTokenStateByAddress[tokenAddress] = { - balance, - allowance, - isLoaded: true, - }; + balanceAndAllowanceTupleByAddress[tokenAddress] = balanceAndAllowanceTuple; } + const pricesByAddress = await this._getPricesByAddressAsync(tokenAddresses); + const trackedTokenStateByAddress = _.reduce( + tokenAddresses, + (acc, address) => { + const [balance, allowance] = balanceAndAllowanceTupleByAddress[address]; + const price = pricesByAddress[address]; + acc[address] = { + balance, + allowance, + price, + isLoaded: true, + }; + return acc; + }, + this.state.trackedTokenStateByAddress, + ); + if (!this._isUnmounted) { this.setState({ trackedTokenStateByAddress, @@ -454,21 +485,21 @@ export class Wallet extends React.Component<WalletProps, WalletState> { } } private async _refetchTokenStateAsync(tokenAddress: string) { - const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress; - const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync( - userAddressIfExists, - tokenAddress, - ); - this.setState({ - trackedTokenStateByAddress: { - ...this.state.trackedTokenStateByAddress, - [tokenAddress]: { - balance, - allowance, - isLoaded: true, - }, - }, - }); + await this._fetchBalancesAndAllowancesAsync([tokenAddress]); + } + private async _getPricesByAddressAsync(tokenAddresses: string[]): Promise<ItemByAddress<BigNumber>> { + if (_.isEmpty(tokenAddresses)) { + return {}; + } + try { + const websiteBackendPriceInfos = await backendClient.getPriceInfosAsync(tokenAddresses); + const addresses = _.map(websiteBackendPriceInfos, info => info.address); + const prices = _.map(websiteBackendPriceInfos, info => new BigNumber(info.price)); + const pricesByAddress = _.zipObject(addresses, prices); + return pricesByAddress; + } catch (err) { + return {}; + } } private _openWrappedEtherActionRow(wrappedEtherDirection: Side) { this.setState({ @@ -485,4 +516,4 @@ export class Wallet extends React.Component<WalletProps, WalletState> { const etherToken = _.find(tokens, { symbol: ETHER_TOKEN_SYMBOL }); return etherToken; } -} +} // tslint:disable:max-file-line-count diff --git a/packages/website/ts/pages/wiki/wiki.tsx b/packages/website/ts/pages/wiki/wiki.tsx index 1330cbf86..7ed2b750d 100644 --- a/packages/website/ts/pages/wiki/wiki.tsx +++ b/packages/website/ts/pages/wiki/wiki.tsx @@ -19,6 +19,7 @@ import { SidebarHeader } from 'ts/components/sidebar_header'; import { TopBar } from 'ts/components/top_bar/top_bar'; import { Dispatcher } from 'ts/redux/dispatcher'; import { Article, ArticlesBySection, WebsitePaths } from 'ts/types'; +import { backendClient } from 'ts/utils/backend_client'; import { configs } from 'ts/utils/configs'; import { constants } from 'ts/utils/constants'; import { Translate } from 'ts/utils/translate'; @@ -200,34 +201,30 @@ export class Wiki extends React.Component<WikiProps, WikiState> { ); } 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(() => { - // tslint:disable-next-line:no-floating-promises - 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(); - logUtils.log(`Failed to load wiki: ${response.status} ${errMsg}`); - return; - } - const articlesBySection = await response.json(); - if (!this._isUnmounted) { - this.setState( - { - articlesBySection, - }, - async () => { - await utils.onPageLoadAsync(); - const hash = this.props.location.hash.slice(1); - sharedUtils.scrollToHash(hash, sharedConstants.SCROLL_CONTAINER_ID); - }, - ); + try { + const articlesBySection = await backendClient.getWikiArticlesBySectionAsync(); + if (!this._isUnmounted) { + this.setState( + { + articlesBySection, + }, + async () => { + await utils.onPageLoadAsync(); + const hash = this.props.location.hash.slice(1); + sharedUtils.scrollToHash(hash, sharedConstants.SCROLL_CONTAINER_ID); + }, + ); + } + } catch (err) { + const errMsg = `${err}`; + if (_.includes(errMsg, `${constants.HTTP_NO_CONTENT_STATUS_CODE}`)) { + // We need to backoff and try fetching again later + this._wikiBackoffTimeoutId = window.setTimeout(() => { + // tslint:disable-next-line:no-floating-promises + this._fetchArticlesBySectionAsync(); + }, WIKI_NOT_READY_BACKOUT_TIMEOUT_MS); + return; + } } } private _getMenuSubsectionsBySection(articlesBySection: ArticlesBySection) { diff --git a/packages/website/ts/types.ts b/packages/website/ts/types.ts index 2126c5c7b..98d080afb 100644 --- a/packages/website/ts/types.ts +++ b/packages/website/ts/types.ts @@ -487,14 +487,17 @@ export interface OutdatedWrappedEtherByNetworkId { }; } -export interface TokenStateByAddress { - [address: string]: TokenState; +export interface ItemByAddress<T> { + [address: string]: T; } +export type TokenStateByAddress = ItemByAddress<TokenState>; + export interface TokenState { balance: BigNumber; allowance: BigNumber; isLoaded: boolean; + price?: BigNumber; } export interface RelayerInfo { @@ -504,4 +507,13 @@ export interface RelayerInfo { marketShare: number; topTokens: Token[]; } + +export interface WebsiteBackendPriceInfo { + price: string; + address: string; +} + +export interface WebsiteBackendGasInfo { + average: number; +} // tslint:disable:max-file-line-count diff --git a/packages/website/ts/utils/backend_client.ts b/packages/website/ts/utils/backend_client.ts new file mode 100644 index 000000000..366519856 --- /dev/null +++ b/packages/website/ts/utils/backend_client.ts @@ -0,0 +1,59 @@ +import { BigNumber, logUtils } from '@0xproject/utils'; +import * as _ from 'lodash'; +import * as queryString from 'query-string'; + +import { ArticlesBySection, ItemByAddress, WebsiteBackendGasInfo, WebsiteBackendPriceInfo } from 'ts/types'; +import { configs } from 'ts/utils/configs'; +import { errorReporter } from 'ts/utils/error_reporter'; + +const ETH_GAS_STATION_ENDPOINT = '/eth_gas_station'; +const PRICES_ENDPOINT = '/prices'; +const WIKI_ENDPOINT = '/wiki'; + +export const backendClient = { + async getGasInfoAsync(): Promise<WebsiteBackendGasInfo> { + const result = await requestAsync(ETH_GAS_STATION_ENDPOINT); + return result; + }, + async getPriceInfosAsync(tokenAddresses: string[]): Promise<WebsiteBackendPriceInfo[]> { + if (_.isEmpty(tokenAddresses)) { + return []; + } + const joinedTokenAddresses = tokenAddresses.join(','); + const queryParams = { + tokens: joinedTokenAddresses, + }; + const result = await requestAsync(PRICES_ENDPOINT, queryParams); + return result; + }, + async getWikiArticlesBySectionAsync(): Promise<ArticlesBySection> { + const result = await requestAsync(WIKI_ENDPOINT); + return result; + }, +}; + +async function requestAsync(endpoint: string, queryParams?: object): Promise<any> { + const query = queryStringFromQueryParams(queryParams); + const url = `${configs.BACKEND_BASE_URL}${endpoint}${query}`; + const response = await fetch(url); + if (response.status !== 200) { + const errorText = `Error requesting url: ${url}, ${response.status}: ${response.statusText}`; + logUtils.log(errorText); + const error = Error(errorText); + // tslint:disable-next-line:no-floating-promises + errorReporter.reportAsync(error); + throw error; + } + const result = await response.json(); + return result; +} + +function queryStringFromQueryParams(queryParams?: object): string { + // if params are undefined or empty, return an empty string + if (_.isUndefined(queryParams) || _.isEmpty(queryParams)) { + return ''; + } + // stringify the formatted object + const stringifiedParams = queryString.stringify(queryParams); + return `?${stringifiedParams}`; +} @@ -275,7 +275,7 @@ dependencies: "@types/node" "*" -"@types/query-string@^5.0.1": +"@types/query-string@^5.0.1", "@types/query-string@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-5.1.0.tgz#7f40cdea49ddafa0ea4f3db35fb6c24d3bfd4dcc" @@ -8451,6 +8451,13 @@ query-string@^5.0.1: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.0.0.tgz#8b8f39447b73e8290d6f5e3581779218e9171142" + dependencies: + decode-uri-component "^0.2.0" + strict-uri-encode "^2.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -9930,6 +9937,10 @@ strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + string-editor@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/string-editor/-/string-editor-0.1.2.tgz#f5ff1b5ac4aed7ac6c2fb8de236d1551b20f61d0" |