diff options
Diffstat (limited to 'packages/instant')
58 files changed, 832 insertions, 223 deletions
diff --git a/packages/instant/.DS_Store b/packages/instant/.DS_Store Binary files differnew file mode 100644 index 000000000..9a0cceca6 --- /dev/null +++ b/packages/instant/.DS_Store diff --git a/packages/instant/.dogfood.discharge.json b/packages/instant/.dogfood.discharge.json index ca36b3861..651b3daa6 100644 --- a/packages/instant/.dogfood.discharge.json +++ b/packages/instant/.dogfood.discharge.json @@ -1,12 +1,12 @@ { "domain": "0x-instant-dogfood", - "build_command": "WEBPACK_OUTPUT_PATH=public yarn build:umd:prod", + "build_command": "WEBPACK_OUTPUT_PATH=public dotenv yarn build -- --env.discharge_target=dogfood", "upload_directory": "public", "index_key": "index.html", "error_key": "index.html", "trailing_slashes": true, "cache": 3600, - "aws_profile": "default", + "aws_profile": "0xproject", "aws_region": "us-east-1", "cdn": false, "dns_configured": true diff --git a/packages/instant/.env_example b/packages/instant/.env_example new file mode 100644 index 000000000..234e64bbe --- /dev/null +++ b/packages/instant/.env_example @@ -0,0 +1,7 @@ +INSTANT_ROLLBAR_PUBLISH_TOKEN= +INSTANT_ROLLBAR_CLIENT_TOKEN= +INSTANT_HEAP_ANALYTICS_ID_PRODUCTION= +INSTANT_HEAP_ANALYTICS_ID_DEVELOPMENT= +# if you want to report to heap or rollbar when building in development mode, you can use the following: +# INSTANT_HEAP_FORCE_DEVELOPMENT=true +# INSTANT_ROLLBAR_FORCE_DEVELOPMENT=true
\ No newline at end of file diff --git a/packages/instant/.gitignore b/packages/instant/.gitignore index a99cea187..2e65f192d 100644 --- a/packages/instant/.gitignore +++ b/packages/instant/.gitignore @@ -1,3 +1,4 @@ public/instant.js public/instant.js.map -umd/*
\ No newline at end of file +umd/* +.env
\ No newline at end of file diff --git a/packages/instant/.npmignore b/packages/instant/.npmignore index a4f7810c0..563923652 100644 --- a/packages/instant/.npmignore +++ b/packages/instant/.npmignore @@ -2,4 +2,5 @@ * */ !lib/**/* -!umd/**/*
\ No newline at end of file +!umd/**/* +.env
\ No newline at end of file diff --git a/packages/instant/.production.discharge.json b/packages/instant/.production.discharge.json new file mode 100644 index 000000000..1ce39fdd8 --- /dev/null +++ b/packages/instant/.production.discharge.json @@ -0,0 +1,13 @@ +{ + "domain": "instant.0xproject.com", + "build_command": "dotenv yarn build -- --env.discharge_target=production", + "upload_directory": "umd", + "index_key": "instant.js", + "error_key": "404.html", + "trailing_slashes": true, + "cache": 3600, + "aws_profile": "0xproject", + "aws_region": "us-east-1", + "cdn": true, + "dns_configured": true +} diff --git a/packages/instant/.staging.discharge.json b/packages/instant/.staging.discharge.json index c917a650b..844e3ca4e 100644 --- a/packages/instant/.staging.discharge.json +++ b/packages/instant/.staging.discharge.json @@ -1,12 +1,12 @@ { "domain": "0x-instant-staging", - "build_command": "WEBPACK_OUTPUT_PATH=public yarn build:umd:prod", + "build_command": "WEBPACK_OUTPUT_PATH=public dotenv yarn build -- --env.discharge_target=staging", "upload_directory": "public", "index_key": "index.html", "error_key": "index.html", "trailing_slashes": true, "cache": 3600, - "aws_profile": "default", + "aws_profile": "0xproject", "aws_region": "us-east-1", "cdn": false, "dns_configured": true diff --git a/packages/instant/README.md b/packages/instant/README.md index b83a10508..2092b45d9 100644 --- a/packages/instant/README.md +++ b/packages/instant/README.md @@ -2,39 +2,11 @@ ## Installation -```bash -yarn add @0x/instant -``` - -**Import** - -**CommonJS module** - -```typescript -import { ZeroExInstant } from '@0x/instant'; -``` - -or - -```javascript -var ZeroExInstant = require('@0x/instant').ZeroExInstant; -``` - -If your project is in [TypeScript](https://www.typescriptlang.org/), add the following to your `tsconfig.json`: - -```json -"compilerOptions": { - "typeRoots": ["node_modules/@0x/typescript-typings/types", "node_modules/@types"], -} -``` - -**UMD Module** - -The package is also available as a UMD module named `zeroExInstant`. +The package is available as a UMD module named `zeroExInstant` at https://instant.0xproject.com/instant.js. ```html <head> - <script type="text/javascript" src="[zeroExInstantUMDPath]" charset="utf-8"></script> + <script type="text/javascript" src="https://instant.0xproject.com/instant.js" charset="utf-8"></script> </head> <body> <div id="zeroExInstantContainer"></div> @@ -48,23 +20,33 @@ The package is also available as a UMD module named `zeroExInstant`. ## Deploying -You can deploy a work-in-progress version of 0x Instant at http://0x-instant-dogfood.s3-website-us-east-1.amazonaws.com for easy sharing. +To run any of the following commands you need to configure your `.env` file. There is an example `.env_example` file to show you what values are required. + +You can deploy a work-in-progress version of 0x Instant at http://0x-instant-dogfood.s3-website-us-east-1.amazonaws.com/instant.js for easy sharing. -To build and deploy the site run +To build and deploy the bundle run ``` yarn deploy_dogfood ``` -We also have a staging bucket that is to be updated less frequently can be used to share instant externally: http://0x-instant-staging.s3-website-us-east-1.amazonaws.com/ +We also have a staging bucket that is to be updated less frequently can be used to share a beta version of instant externally: http://0x-instant-staging.s3-website-us-east-1.amazonaws.com/instant.js -To build and deploy to this bucket, run +To build and deploy to this bundle, run ``` yarn deploy_staging ``` -**NOTE: On deploying the site, it will say the site is available at a non-existent URL. Please ignore and use the (now updated) URL above.** +Finally, we have our live production bundle that is only meant to be updated with stable, polished releases: https://instant.0xproject.com/instant.js + +To build and deploy to this bundle, run + +``` +yarn deploy_production +``` + +**NOTE: On deploying the site to staging and dogfood, it will say the site is available at a non-existent URL. Please ignore and use the (now updated) URL above.** ## Contributing diff --git a/packages/instant/package.json b/packages/instant/package.json index 4422dc83f..7d0bf6bec 100644 --- a/packages/instant/package.json +++ b/packages/instant/package.json @@ -1,20 +1,15 @@ { "name": "@0x/instant", - "version": "1.0.1", + "version": "1.0.2", "engines": { "node": ">=6.12" }, "private": true, "description": "0x Instant React Component", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "umd/instant.js", "scripts": { - "build": "yarn build:all", - "build:all": "run-p build:umd:prod build:commonjs", - "build:umd:prod": "webpack --mode production", - "build:commonjs": "tsc -b", + "build": "webpack --mode production", "build:ci": "yarn build", - "watch_without_deps": "tsc -w", "dev": "webpack-dev-server --mode development", "lint": "tslint --format stylish --project .", "test": "jest", @@ -24,6 +19,7 @@ "clean": "shx rm -rf lib coverage scripts", "deploy_dogfood": "discharge deploy -c .dogfood.discharge.json", "deploy_staging": "discharge deploy -c .staging.discharge.json", + "deploy_production": "discharge deploy -c .production.discharge.json", "manual:postpublish": "yarn build; node ./scripts/postpublish.js" }, "config": { @@ -46,14 +42,14 @@ "homepage": "https://github.com/0xProject/0x-monorepo/packages/instant/README.md", "dependencies": { "@0x/assert": "^1.0.18", - "@0x/asset-buyer": "^3.0.1", + "@0x/asset-buyer": "^3.0.2", "@0x/json-schemas": "^2.1.2", - "@0x/order-utils": "^3.0.3", - "@0x/subproviders": "^2.1.5", + "@0x/order-utils": "^3.0.4", + "@0x/subproviders": "^2.1.6", "@0x/types": "^1.3.0", "@0x/typescript-typings": "^3.0.4", "@0x/utils": "^2.0.6", - "@0x/web3-wrapper": "^3.1.5", + "@0x/web3-wrapper": "^3.1.6", "bowser": "^1.9.4", "copy-to-clipboard": "^3.0.8", "ethereum-types": "^1.1.2", @@ -64,6 +60,7 @@ "react-redux": "^5.0.7", "redux": "^4.0.0", "redux-devtools-extension": "^2.13.5", + "rollbar": "^2.5.0", "styled-components": "^4.0.2", "ts-optchain": "^0.1.1" }, @@ -81,6 +78,7 @@ "@types/redux": "^3.6.0", "@types/styled-components": "^4.0.1", "awesome-typescript-loader": "^5.2.1", + "dotenv-cli": "^1.4.0", "enzyme": "^3.6.0", "enzyme-adapter-react-16": "^1.5.0", "ip": "^1.1.5", @@ -88,7 +86,9 @@ "make-promises-safe": "^1.1.0", "npm-run-all": "^4.1.2", "nyc": "^11.0.1", + "rollbar-sourcemap-webpack-plugin": "^2.4.0", "shx": "^0.2.2", + "source-map-loader": "^0.2.4", "svg-react-loader": "^0.4.6", "ts-jest": "^23.10.3", "tslint": "5.11.0", @@ -99,6 +99,6 @@ "webpack-dev-server": "^3.1.9" }, "publishConfig": { - "access": "public" + "access": "private" } } diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html index df39994ef..d10618c58 100644 --- a/packages/instant/public/index.html +++ b/packages/instant/public/index.html @@ -175,6 +175,7 @@ defaultSelectedAssetData: queryParams.getQueryParamValue('defaultSelectedAssetData'), affiliateInfo: affiliateInfoOverride, shouldDisablePushToHistory: !!queryParams.getQueryParamValue('shouldDisablePushToHistory'), + walletDisplayName: queryParams.getQueryParamValue('walletDisplayName') || undefined, }; return renderOptionsOverrides; }; diff --git a/packages/instant/src/assets/icons/zrx.svg b/packages/instant/src/assets/icons/zrx.svg index 07518f551..da623710b 100644 --- a/packages/instant/src/assets/icons/zrx.svg +++ b/packages/instant/src/assets/icons/zrx.svg @@ -1,3 +1,6 @@ -<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path fill-rule="evenodd" clip-rule="evenodd" d="M22.6726 18.5002L22.6787 18.5063L22.625 18.5868C22.641 18.558 22.6569 18.5291 22.6726 18.5002V18.5002H22.6726ZM22.7893 18.5002L21.3866 17.0053L17.9692 12.9131L19.6779 11.0919L17.1249 7.89955L18.8337 6.07835L20.0599 4.74999C20.663 5.26419 21.2058 5.83552 21.6882 6.46394C22.1707 7.09242 22.5828 7.77444 22.9245 8.5101C23.2663 9.2457 23.5309 10.0206 23.7186 10.8347C23.9062 11.6489 24 12.4916 24 13.3628C24 14.3055 23.8928 15.216 23.6784 16.0945C23.4703 16.9468 23.174 17.7487 22.7893 18.5L22.7893 18.5002ZM6.74427 15.3604L8.87512 18.1019L7.20654 19.8795L5.94009 21.2502L5.91999 21.2288C5.3169 20.7291 4.77415 20.1651 4.29169 19.5368C3.80923 18.9086 3.39714 18.2268 3.05539 17.4915C2.71365 16.7562 2.45567 15.9816 2.28144 15.1677C2.09382 14.3539 2 13.5114 2 12.6405C2 11.6981 2.10721 10.7913 2.32164 9.92041C2.53608 9.04943 2.83091 8.24276 3.20615 7.50025L4.61334 8.99943L8.45293 13.54L6.7442 15.3605L6.74427 15.3604ZM7.89849 8.87512L6.12088 7.20654L4.75015 5.94009L4.77157 5.91999C5.27132 5.3169 5.83531 4.77415 6.46352 4.29169C7.09178 3.80923 7.77357 3.39714 8.50886 3.05539C9.2442 2.71365 10.0188 2.45567 10.8326 2.28144C11.6465 2.09382 12.489 2 13.3599 2C14.3023 2 15.209 2.10721 16.08 2.32164C16.9509 2.53608 17.7576 2.83091 18.5001 3.20615L17.0866 4.55302L12.9101 8.0307L11.0896 6.32197L7.89835 8.87496L7.89849 8.87512ZM18.1019 17.1252L19.8795 18.7938L21.2503 20.0602L21.2288 20.0803C20.7291 20.6834 20.1651 21.2262 19.5369 21.7086C18.9086 22.1911 18.2268 22.6032 17.4916 22.9449C16.7562 23.2867 15.9817 23.5447 15.1678 23.7189C14.3539 23.9065 13.5114 24.0003 12.6405 24.0003C11.6981 24.0003 10.7914 23.8931 9.92046 23.6787C9.04952 23.4643 8.2428 23.1694 7.50029 22.7942L8.91381 21.4473L13.54 17.5474L15.3606 19.6782L18.1021 17.1252L18.1019 17.1252Z" fill="white"/> +<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M4.62099 13.9044L6.3287 12.1375L4.20565 9.27256L1.50251 5.44771C0.547534 7.07749 0 8.97462 0 11C0 14.3552 1.50251 17.3593 3.8722 19.3768L7.30341 16.9518C6.13632 16.2248 5.19614 15.1662 4.62099 13.9044Z" fill="white"/> +<path d="M8.09561 4.62099L9.86251 6.3287L12.7274 4.20565L16.5523 1.50251C14.9225 0.547534 13.0254 0 11 0C7.64475 0 4.64072 1.50251 2.62323 3.8722L5.04816 7.30341C5.77525 6.13632 6.83381 5.19614 8.09561 4.62099Z" fill="white"/> +<path d="M15.6713 9.86251L17.7943 12.7274L20.4975 16.5523C21.4525 14.9225 22 13.0254 22 11C22 7.64475 20.4975 4.64072 18.1278 2.62323L14.6966 5.04816C15.8637 5.77525 16.8039 6.83381 17.379 8.09561L15.6713 9.86251Z" fill="white"/> +<path d="M19.3768 18.1278L16.9518 14.6966C16.2248 15.8637 15.1662 16.8039 13.9044 17.379L12.1375 15.6713L9.27256 17.7943L5.44771 20.4975C7.07749 21.4525 8.97462 22 11 22C14.3552 22 17.3593 20.4975 19.3768 18.1278Z" fill="white"/> </svg> diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx index 8b6121e43..1489b94d4 100644 --- a/packages/instant/src/components/buy_button.tsx +++ b/packages/instant/src/components/buy_button.tsx @@ -1,4 +1,5 @@ import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; @@ -7,7 +8,8 @@ import { oc } from 'ts-optchain'; import { WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX } from '../constants'; import { ColorOption } from '../style/theme'; -import { AffiliateInfo, ZeroExInstantError } from '../types'; +import { AffiliateInfo, Asset, ZeroExInstantError } from '../types'; +import { analytics } from '../util/analytics'; import { gasPriceEstimator } from '../util/gas_price_estimator'; import { util } from '../util/util'; @@ -20,6 +22,7 @@ export interface BuyButtonProps { assetBuyer: AssetBuyer; web3Wrapper: Web3Wrapper; affiliateInfo?: AffiliateInfo; + selectedAsset?: Asset; onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; onSignatureDenied: (buyQuote: BuyQuote) => void; @@ -35,8 +38,12 @@ export class BuyButton extends React.Component<BuyButtonProps> { onBuyFailure: util.boundNoop, }; public render(): React.ReactNode { - const { buyQuote, accountAddress } = this.props; + const { buyQuote, accountAddress, selectedAsset } = this.props; const shouldDisableButton = _.isUndefined(buyQuote) || _.isUndefined(accountAddress); + const buttonText = + !_.isUndefined(selectedAsset) && selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20 + ? `Buy ${selectedAsset.metaData.symbol.toUpperCase()}` + : 'Buy Now'; return ( <Button width="100%" @@ -44,7 +51,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { isDisabled={shouldDisableButton} fontColor={ColorOption.white} > - Buy + {buttonText} </Button> ); } @@ -59,6 +66,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { // if we don't have a balance for the user, let the transaction through, it will be handled by the wallet const hasSufficientEth = _.isUndefined(accountEthBalanceInWei) || accountEthBalanceInWei.gte(ethNeededForBuy); if (!hasSufficientEth) { + analytics.trackBuyNotEnoughEth(buyQuote); this.props.onValidationFail(buyQuote, ZeroExInstantError.InsufficientETH); return; } @@ -66,6 +74,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { const gasInfo = await gasPriceEstimator.getGasInfoAsync(); const feeRecipient = oc(affiliateInfo).feeRecipient(); try { + analytics.trackBuyStarted(buyQuote); txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { feeRecipient, takerAddress: accountAddress, @@ -74,9 +83,11 @@ export class BuyButton extends React.Component<BuyButtonProps> { } catch (e) { if (e instanceof Error) { if (e.message === AssetBuyerError.SignatureRequestDenied) { + analytics.trackBuySignatureDenied(buyQuote); this.props.onSignatureDenied(buyQuote); return; } else if (e.message === AssetBuyerError.TransactionValueTooLow) { + analytics.trackBuySimulationFailed(buyQuote); this.props.onValidationFail(buyQuote, AssetBuyerError.TransactionValueTooLow); return; } @@ -87,14 +98,17 @@ export class BuyButton extends React.Component<BuyButtonProps> { const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs; this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); try { + analytics.trackBuyTxSubmitted(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); await web3Wrapper.awaitTransactionSuccessAsync(txHash); } catch (e) { if (e instanceof Error && e.message.startsWith(WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX)) { + analytics.trackBuyTxFailed(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); this.props.onBuyFailure(buyQuote, txHash); return; } throw e; } + analytics.trackBuyTxSucceeded(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); this.props.onBuySuccess(buyQuote, txHash); }; } diff --git a/packages/instant/src/components/buy_order_progress.tsx b/packages/instant/src/components/buy_order_progress.tsx index 6568de91b..a19f5a4d0 100644 --- a/packages/instant/src/components/buy_order_progress.tsx +++ b/packages/instant/src/components/buy_order_progress.tsx @@ -21,7 +21,7 @@ export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = const hasEnded = buyOrderState.processState !== OrderProcessState.Processing; const expectedTimeMs = progress.expectedEndTimeUnix - progress.startTimeUnix; return ( - <Container padding="20px 20px 0px 20px" width="100%"> + <Container width="100%" padding="20px 20px 0px 20px"> <Container marginBottom="5px"> <TimeCounter estimatedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> </Container> diff --git a/packages/instant/src/components/buy_order_state_buttons.tsx b/packages/instant/src/components/buy_order_state_buttons.tsx index e563bec73..833818900 100644 --- a/packages/instant/src/components/buy_order_state_buttons.tsx +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -4,7 +4,7 @@ import { Web3Wrapper } from '@0x/web3-wrapper'; import * as React from 'react'; import { ColorOption } from '../style/theme'; -import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; +import { AffiliateInfo, Asset, OrderProcessState, ZeroExInstantError } from '../types'; import { BuyButton } from './buy_button'; import { PlacingOrderButton } from './placing_order_button'; @@ -21,6 +21,7 @@ export interface BuyOrderStateButtonProps { assetBuyer: AssetBuyer; web3Wrapper: Web3Wrapper; affiliateInfo?: AffiliateInfo; + selectedAsset?: Asset; onViewTransaction: () => void; onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; @@ -60,6 +61,7 @@ export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonP assetBuyer={props.assetBuyer} web3Wrapper={props.web3Wrapper} affiliateInfo={props.affiliateInfo} + selectedAsset={props.selectedAsset} onValidationPending={props.onValidationPending} onValidationFail={props.onValidationFail} onSignatureDenied={props.onSignatureDenied} diff --git a/packages/instant/src/components/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx index ff900842a..4da82eb73 100644 --- a/packages/instant/src/components/erc20_asset_amount_input.tsx +++ b/packages/instant/src/components/erc20_asset_amount_input.tsx @@ -113,7 +113,7 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput ); }; private readonly _renderChevronIcon = (): React.ReactNode => { - if (!this._areMultipleAssetsAvailable()) { + if (!this._areAnyAssetsAvailable()) { return null; } return ( @@ -134,14 +134,14 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput // We don't want to allow opening the token selection panel if there are no assets. // Since styles are inferred from the presence of a click handler, we want to return undefined // instead of providing a noop. - if (!this._areMultipleAssetsAvailable() || _.isUndefined(this.props.onSelectAssetClick)) { + if (!this._areAnyAssetsAvailable() || _.isUndefined(this.props.onSelectAssetClick)) { return undefined; } return this._handleSelectAssetClick; }; - private readonly _areMultipleAssetsAvailable = (): boolean => { + private readonly _areAnyAssetsAvailable = (): boolean => { const { numberOfAssetsAvailable } = this.props; - return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 1; + return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 0; }; private readonly _handleSelectAssetClick = (): void => { if (this.props.onSelectAssetClick) { diff --git a/packages/instant/src/components/erc20_token_selector.tsx b/packages/instant/src/components/erc20_token_selector.tsx index 1b1921acb..f7d5a4fe4 100644 --- a/packages/instant/src/components/erc20_token_selector.tsx +++ b/packages/instant/src/components/erc20_token_selector.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ColorOption } from '../style/theme'; import { ERC20Asset } from '../types'; +import { analytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { SearchInput } from './search_input'; @@ -18,12 +19,12 @@ export interface ERC20TokenSelectorProps { } export interface ERC20TokenSelectorState { - searchQuery?: string; + searchQuery: string; } export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> { public state: ERC20TokenSelectorState = { - searchQuery: undefined, + searchQuery: '', }; public render(): React.ReactNode { const { tokens, onTokenSelect } = this.props; @@ -57,13 +58,14 @@ export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> this.setState({ searchQuery, }); + analytics.trackTokenSelectorSearched(searchQuery); }; private readonly _isTokenQueryMatch = (token: ERC20Asset): boolean => { const { searchQuery } = this.state; - if (_.isUndefined(searchQuery)) { + const searchQueryLowerCase = searchQuery.toLowerCase().trim(); + if (searchQueryLowerCase === '') { return true; } - const searchQueryLowerCase = searchQuery.toLowerCase(); const tokenName = token.metaData.name.toLowerCase(); const tokenSymbol = token.metaData.symbol.toLowerCase(); return _.startsWith(tokenSymbol, searchQueryLowerCase) || _.startsWith(tokenName, searchQueryLowerCase); diff --git a/packages/instant/src/components/install_wallet_panel_content.tsx b/packages/instant/src/components/install_wallet_panel_content.tsx index 88c26f59c..481d82da0 100644 --- a/packages/instant/src/components/install_wallet_panel_content.tsx +++ b/packages/instant/src/components/install_wallet_panel_content.tsx @@ -8,7 +8,9 @@ import { } from '../constants'; import { ColorOption } from '../style/theme'; import { Browser } from '../types'; +import { analytics } from '../util/analytics'; import { envUtil } from '../util/env'; +import { util } from '../util/util'; import { MetaMaskLogo } from './meta_mask_logo'; import { StandardPanelContent, StandardPanelContentProps } from './standard_panel_content'; @@ -45,6 +47,10 @@ export class InstallWalletPanelContent extends React.Component<InstallWalletPane default: break; } + const onActionClick = () => { + analytics.trackInstallWalletModalClickedGet(); + util.createOpenUrlInNewWindow(actionUrl)(); + }; return { image: <MetaMaskLogo width={85} height={80} />, title: 'Install MetaMask', @@ -52,10 +58,11 @@ export class InstallWalletPanelContent extends React.Component<InstallWalletPane moreInfoSettings: { href: META_MASK_SITE_URL, text: 'What is MetaMask?', + onClick: analytics.trackInstallWalletModalClickedExplanation, }, action: ( <Button - href={actionUrl} + onClick={onActionClick} width="100%" fontColor={ColorOption.white} backgroundColor={ColorOption.darkOrange} diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index ace577824..117f9dd5f 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -32,7 +32,7 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { public render(): React.ReactNode { const iconOrAmounts = this._renderIcon() || this._renderAmountsSection(); return ( - <Container backgroundColor={ColorOption.primaryColor} padding="20px" width="100%"> + <Container backgroundColor={ColorOption.primaryColor} width="100%" padding="20px"> <Container marginBottom="5px"> <Text letterSpacing="1px" @@ -107,7 +107,14 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { private readonly _renderEthAmount = (): React.ReactNode => { return ( - <Text fontSize="16px" textAlign="right" width="100%" fontColor={ColorOption.white} fontWeight={500}> + <Text + fontSize="16px" + textAlign="right" + width="100%" + fontColor={ColorOption.white} + fontWeight={500} + noWrap={true} + > {format.ethBaseUnitAmount( this.props.totalEthBaseUnitAmount, 4, @@ -119,7 +126,7 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { private readonly _renderDollarAmount = (): React.ReactNode => { return ( - <Text fontSize="16px" textAlign="right" width="100%" fontColor={ColorOption.white}> + <Text fontSize="16px" textAlign="right" width="100%" fontColor={ColorOption.white} noWrap={true}> {format.ethBaseUnitAmountInUsd( this.props.totalEthBaseUnitAmount, this.props.ethUsdPrice, diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx index 5fc956e1c..a8e0e2513 100644 --- a/packages/instant/src/components/order_details.tsx +++ b/packages/instant/src/components/order_details.tsx @@ -34,7 +34,7 @@ export class OrderDetails extends React.Component<OrderDetailsProps> { ? assetEthBaseUnitAmount.div(selectedAssetUnitAmount).ceil() : undefined; return ( - <Container padding="20px" width="100%" flexGrow={1}> + <Container width="100%" flexGrow={1} padding="20px 20px 0px 20px"> <Container marginBottom="10px"> <Text letterSpacing="1px" diff --git a/packages/instant/src/components/payment_method.tsx b/packages/instant/src/components/payment_method.tsx index ebcd62f35..7c93f1d1c 100644 --- a/packages/instant/src/components/payment_method.tsx +++ b/packages/instant/src/components/payment_method.tsx @@ -18,7 +18,7 @@ import { WalletPrompt } from './wallet_prompt'; export interface PaymentMethodProps { account: Account; network: Network; - walletName: string; + walletDisplayName: string; onInstallWalletClick: () => void; onUnlockWalletClick: () => void; } @@ -26,7 +26,7 @@ export interface PaymentMethodProps { export class PaymentMethod extends React.Component<PaymentMethodProps> { public render(): React.ReactNode { return ( - <Container padding="20px" width="100%"> + <Container width="100%" height="120px" padding="20px 20px 0px 20px"> <Container marginBottom="12px"> <Flex justify="space-between"> <Text @@ -62,11 +62,11 @@ export class PaymentMethod extends React.Component<PaymentMethodProps> { if (account.state === AccountState.Ready || account.state === AccountState.Locked) { const circleColor: ColorOption = account.state === AccountState.Ready ? ColorOption.green : ColorOption.red; return ( - <Flex> + <Flex align="center"> <Circle diameter={8} color={circleColor} /> - <Container marginLeft="3px"> - <Text fontColor={ColorOption.darkGrey} fontSize="12px"> - {this.props.walletName} + <Container marginLeft="5px"> + <Text fontColor={ColorOption.darkGrey} fontSize="12px" lineHeight="30px"> + {this.props.walletDisplayName} </Text> </Container> </Flex> @@ -83,16 +83,19 @@ export class PaymentMethod extends React.Component<PaymentMethodProps> { const colors = { primaryColor, secondaryColor }; switch (account.state) { case AccountState.Loading: - // Just take up the same amount of space as the other states. - return <Container height="52px" />; + return null; case AccountState.Locked: return ( <WalletPrompt onClick={this.props.onUnlockWalletClick} - image={<Icon width={13} icon="lock" color={ColorOption.black} />} + image={ + <Container position="relative" top="2px"> + <Icon width={13} icon="lock" color={ColorOption.black} /> + </Container> + } {...colors} > - Please Unlock {this.props.walletName} + Click to Connect {this.props.walletDisplayName} </WalletPrompt> ); case AccountState.None: diff --git a/packages/instant/src/components/payment_method_dropdown.tsx b/packages/instant/src/components/payment_method_dropdown.tsx index b330dbcd6..872ac0831 100644 --- a/packages/instant/src/components/payment_method_dropdown.tsx +++ b/packages/instant/src/components/payment_method_dropdown.tsx @@ -1,8 +1,9 @@ import { BigNumber } from '@0x/utils'; -import copy from 'copy-to-clipboard'; +import * as copy from 'copy-to-clipboard'; import * as React from 'react'; import { Network } from '../types'; +import { analytics } from '../util/analytics'; import { envUtil } from '../util/env'; import { etherscanUtil } from '../util/etherscan'; import { format } from '../util/format'; @@ -20,7 +21,14 @@ export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdown const { accountAddress, accountEthBalanceInWei } = this.props; const value = format.ethAddress(accountAddress); const label = format.ethBaseUnitAmount(accountEthBalanceInWei, 4, '') as string; - return <Dropdown value={value} label={label} items={this._getDropdownItemConfigs()} />; + return ( + <Dropdown + value={value} + label={label} + items={this._getDropdownItemConfigs()} + onOpen={analytics.trackPaymentMethodDropdownOpened} + /> + ); } private readonly _getDropdownItemConfigs = (): DropdownItemConfig[] => { if (envUtil.isMobileOperatingSystem()) { @@ -37,11 +45,15 @@ export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdown return [viewOnEtherscan, copyAddressToClipboard]; }; private readonly _handleEtherscanClick = (): void => { + analytics.trackPaymentMethodOpenedEtherscan(); + const { accountAddress, network } = this.props; const etherscanUrl = etherscanUtil.getEtherScanEthAddressIfExists(accountAddress, network); window.open(etherscanUrl, '_blank'); }; private readonly _handleCopyToClipboardClick = (): void => { + analytics.trackPaymentMethodCopiedAddress(); + const { accountAddress } = this.props; copy(accountAddress); }; diff --git a/packages/instant/src/components/scaling_amount_input.tsx b/packages/instant/src/components/scaling_amount_input.tsx index 0861bbe05..86aca5a65 100644 --- a/packages/instant/src/components/scaling_amount_input.tsx +++ b/packages/instant/src/components/scaling_amount_input.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Maybe } from '../types'; +import { GIT_SHA, MAGIC_TRIGGER_ERROR_INPUT, MAGIC_TRIGGER_ERROR_MESSAGE, NPM_PACKAGE_VERSION } from '../constants'; import { ColorOption } from '../style/theme'; import { maybeBigNumberUtil } from '../util/maybe_big_number'; import { util } from '../util/util'; @@ -71,6 +72,10 @@ export class ScalingAmountInput extends React.Component<ScalingAmountInputProps, ); } private readonly _handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + if (event.target.value === MAGIC_TRIGGER_ERROR_INPUT) { + throw new Error(`${MAGIC_TRIGGER_ERROR_MESSAGE} git: ${GIT_SHA}, npm: ${NPM_PACKAGE_VERSION}`); + } + const sanitizedValue = event.target.value.replace(/[^0-9.]/g, ''); // only allow numbers and "." this.setState({ stringValue: sanitizedValue, diff --git a/packages/instant/src/components/scaling_input.tsx b/packages/instant/src/components/scaling_input.tsx index 129162a74..791692257 100644 --- a/packages/instant/src/components/scaling_input.tsx +++ b/packages/instant/src/components/scaling_input.tsx @@ -98,6 +98,12 @@ export class ScalingInput extends React.Component<ScalingInputProps, ScalingInpu inputWidthPx: this._getInputWidthInPx(), }; } + public componentDidMount(): void { + // Trigger an initial notification of the calculated fontSize. + const currentPhase = ScalingInput.getPhaseFromProps(this.props); + const currentFontSize = ScalingInput.calculateFontSizeFromProps(this.props, currentPhase); + this.props.onFontSizeChange(currentFontSize); + } public componentDidUpdate( prevProps: ScalingInputProps, prevState: ScalingInputState, diff --git a/packages/instant/src/components/search_input.tsx b/packages/instant/src/components/search_input.tsx index 3a693b9f8..71bc18915 100644 --- a/packages/instant/src/components/search_input.tsx +++ b/packages/instant/src/components/search_input.tsx @@ -13,10 +13,10 @@ export interface SearchInputProps extends InputProps { } export const SearchInput: React.StatelessComponent<SearchInputProps> = props => ( - <Container backgroundColor={props.backgroundColor} borderRadius="3px" padding=".5em .3em"> - <Flex justify="flex-start" align="flex-end"> - <Icon width={14} height={14} icon="search" color={ColorOption.lightGrey} padding="0px 12px" /> - <Input {...props} fontSize="14px" fontColor={props.fontColor} /> + <Container backgroundColor={props.backgroundColor} borderRadius="3px" padding=".5em .5em"> + <Flex justify="flex-start" align="center"> + <Icon width={14} height={14} icon="search" color={ColorOption.lightGrey} padding="2px 12px" /> + <Input {...props} type="search" fontSize="16px" fontColor={props.fontColor} /> </Flex> </Container> ); diff --git a/packages/instant/src/components/standard_panel_content.tsx b/packages/instant/src/components/standard_panel_content.tsx index 582b3318e..79b7bff24 100644 --- a/packages/instant/src/components/standard_panel_content.tsx +++ b/packages/instant/src/components/standard_panel_content.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ColorOption } from '../style/theme'; +import { util } from '../util/util'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; @@ -9,6 +10,7 @@ import { Text } from './ui/text'; export interface MoreInfoSettings { text: string; href: string; + onClick?: () => void; } export interface StandardPanelContentProps { @@ -21,6 +23,15 @@ export interface StandardPanelContentProps { const SPACING_BETWEEN_PX = '20px'; +const onMoreInfoClick = (href: string, onClick?: () => void) => { + return () => { + if (onClick) { + onClick(); + } + util.createOpenUrlInNewWindow(href)(); + }; +}; + export const StandardPanelContent: React.StatelessComponent<StandardPanelContentProps> = ({ image, title, @@ -50,7 +61,7 @@ export const StandardPanelContent: React.StatelessComponent<StandardPanelContent fontSize="13px" textDecorationLine="underline" fontColor={ColorOption.lightGrey} - href={moreInfoSettings.href} + onClick={onMoreInfoClick(moreInfoSettings.href, moreInfoSettings.onClick)} > {moreInfoSettings.text} </Text> diff --git a/packages/instant/src/components/standard_sliding_panel.tsx b/packages/instant/src/components/standard_sliding_panel.tsx index f587ff79a..9f517d273 100644 --- a/packages/instant/src/components/standard_sliding_panel.tsx +++ b/packages/instant/src/components/standard_sliding_panel.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { SlideAnimationState, StandardSlidingPanelContent, StandardSlidingPanelSettings } from '../types'; +import { StandardSlidingPanelContent, StandardSlidingPanelSettings } from '../types'; import { InstallWalletPanelContent } from './install_wallet_panel_content'; import { SlidingPanel } from './sliding_panel'; diff --git a/packages/instant/src/components/timed_progress_bar.tsx b/packages/instant/src/components/timed_progress_bar.tsx index 8465b9cd0..fb3927088 100644 --- a/packages/instant/src/components/timed_progress_bar.tsx +++ b/packages/instant/src/components/timed_progress_bar.tsx @@ -1,8 +1,9 @@ import * as _ from 'lodash'; +import { transparentize } from 'polished'; import * as React from 'react'; import { PROGRESS_FINISH_ANIMATION_TIME_MS, PROGRESS_STALL_AT_WIDTH } from '../constants'; -import { ColorOption, css, keyframes, styled } from '../style/theme'; +import { ColorOption, css, keyframes, styled, ThemeConsumer } from '../style/theme'; import { Container } from './ui/container'; @@ -93,8 +94,16 @@ export interface ProgressBarProps extends ProgressProps {} export const ProgressBar: React.ComponentType<ProgressBarProps & React.ClassAttributes<{}>> = React.forwardRef( (props, ref) => ( - <Container width="100%" backgroundColor={ColorOption.lightGrey} borderRadius="6px"> - <Progress {...props} ref={ref as any} /> - </Container> + <ThemeConsumer> + {theme => ( + <Container + width="100%" + borderRadius="6px" + rawBackgroundColor={transparentize(0.5, theme[ColorOption.primaryColor])} + > + <Progress {...props} ref={ref as any} /> + </Container> + )} + </ThemeConsumer> ), ); diff --git a/packages/instant/src/components/ui/container.tsx b/packages/instant/src/components/ui/container.tsx index 4dafe1386..636eb8fc9 100644 --- a/packages/instant/src/components/ui/container.tsx +++ b/packages/instant/src/components/ui/container.tsx @@ -27,7 +27,9 @@ export interface ContainerProps { borderBottom?: string; className?: string; backgroundColor?: ColorOption; + rawBackgroundColor?: string; hasBoxShadow?: boolean; + isHidden?: boolean; zIndex?: number; whiteSpace?: string; opacity?: number; @@ -38,6 +40,16 @@ export interface ContainerProps { flexGrow?: string | number; } +const getBackgroundColor = (theme: any, backgroundColor?: ColorOption, rawBackgroundColor?: string): string => { + if (backgroundColor) { + return theme[backgroundColor] as string; + } + if (rawBackgroundColor) { + return rawBackgroundColor; + } + return 'none'; +}; + export const Container = styled.div < ContainerProps > @@ -65,12 +77,14 @@ export const Container = ${props => cssRuleIfExists(props, 'opacity')} ${props => cssRuleIfExists(props, 'cursor')} ${props => cssRuleIfExists(props, 'overflow')} + ${props => (props.overflow === 'scroll' ? `-webkit-overflow-scrolling: touch` : '')}; ${props => (props.hasBoxShadow ? `box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1)` : '')}; ${props => props.display && stylesForMedia<string>('display', props.display)} ${props => props.width && stylesForMedia<string>('width', props.width)} ${props => props.height && stylesForMedia<string>('height', props.height)} ${props => props.borderRadius && stylesForMedia<string>('border-radius', props.borderRadius)} - background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')}; + ${props => (props.isHidden ? 'visibility: hidden;' : '')} + background-color: ${props => getBackgroundColor(props.theme, props.backgroundColor, props.rawBackgroundColor)}; border-color: ${props => (props.borderColor ? props.theme[props.borderColor] : 'none')}; &:hover { ${props => diff --git a/packages/instant/src/components/ui/dropdown.tsx b/packages/instant/src/components/ui/dropdown.tsx index 3a23f456d..02e87d639 100644 --- a/packages/instant/src/components/ui/dropdown.tsx +++ b/packages/instant/src/components/ui/dropdown.tsx @@ -19,6 +19,7 @@ export interface DropdownProps { value: string; label?: string; items: DropdownItemConfig[]; + onOpen?: () => void; } export interface DropdownState { @@ -97,9 +98,14 @@ export class Dropdown extends React.Component<DropdownProps, DropdownState> { if (_.isEmpty(this.props.items)) { return; } + const isOpen = !this.state.isOpen; this.setState({ - isOpen: !this.state.isOpen, + isOpen, }); + + if (isOpen && this.props.onOpen) { + this.props.onOpen(); + } }; private readonly _closeDropdown = (): void => { this.setState({ diff --git a/packages/instant/src/components/ui/input.tsx b/packages/instant/src/components/ui/input.tsx index 1ea5d8fe1..863c970ef 100644 --- a/packages/instant/src/components/ui/input.tsx +++ b/packages/instant/src/components/ui/input.tsx @@ -10,6 +10,7 @@ export interface InputProps { fontSize?: string; fontColor?: ColorOption; placeholder?: string; + type?: string; onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void; } diff --git a/packages/instant/src/components/ui/overlay.tsx b/packages/instant/src/components/ui/overlay.tsx index f67d6fb2f..0b5eaf299 100644 --- a/packages/instant/src/components/ui/overlay.tsx +++ b/packages/instant/src/components/ui/overlay.tsx @@ -33,7 +33,7 @@ export const Overlay = Overlay.defaultProps = { zIndex: zIndex.overlayDefault, - backgroundColor: generateOverlayBlack(0.6), + backgroundColor: generateOverlayBlack(0.7), }; Overlay.displayName = 'Overlay'; diff --git a/packages/instant/src/components/ui/text.tsx b/packages/instant/src/components/ui/text.tsx index 8e573d7b9..282477758 100644 --- a/packages/instant/src/components/ui/text.tsx +++ b/packages/instant/src/components/ui/text.tsx @@ -1,4 +1,3 @@ -import { darken } from 'polished'; import * as React from 'react'; import { ColorOption, styled } from '../../style/theme'; @@ -31,7 +30,7 @@ export const Text: React.StatelessComponent<TextProps> = ({ href, onClick, ...re return <StyledText {...rest} onClick={computedOnClick} />; }; -const darkenOnHoverAmount = 0.3; +const opacityOnHoverAmount = 0.5; export const StyledText = styled.div < TextProps > @@ -56,8 +55,7 @@ export const StyledText = ${props => (props.textAlign ? `text-align: ${props.textAlign}` : '')}; ${props => (props.width ? `width: ${props.width}` : '')}; &:hover { - ${props => - props.onClick ? `color: ${darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` : ''}; + ${props => (props.onClick ? `opacity: ${opacityOnHoverAmount};` : '')}; } } `; diff --git a/packages/instant/src/components/wallet_prompt.tsx b/packages/instant/src/components/wallet_prompt.tsx index a0b3ae457..c07cfe7b5 100644 --- a/packages/instant/src/components/wallet_prompt.tsx +++ b/packages/instant/src/components/wallet_prompt.tsx @@ -21,7 +21,7 @@ export const WalletPrompt: React.StatelessComponent<WalletPromptProps> = ({ primaryColor, }) => ( <Container - padding="14.5px" + padding="10px" border={`1px solid ${primaryColor}`} backgroundColor={secondaryColor} width="100%" @@ -33,7 +33,7 @@ export const WalletPrompt: React.StatelessComponent<WalletPromptProps> = ({ <Flex> {image} <Container marginLeft="10px"> - <Text fontSize="16px" fontColor={primaryColor}> + <Text fontSize="16px" fontColor={primaryColor} fontWeight="500"> {children} </Text> </Container> diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index 47c938472..0337c7714 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -11,21 +11,20 @@ import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_ import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading'; import { ColorOption } from '../style/theme'; import { zIndex } from '../style/z_index'; -import { OrderProcessState, SlideAnimationState } from '../types'; +import { SlideAnimationState } from '../types'; +import { analytics, TokenSelectorClosedVia } from '../util/analytics'; import { CSSReset } from './css_reset'; import { SlidingPanel } from './sliding_panel'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; -export interface ZeroExInstantContainerProps { - orderProcessState: OrderProcessState; -} +export interface ZeroExInstantContainerProps {} export interface ZeroExInstantContainerState { tokenSelectionPanelAnimationState: SlideAnimationState; } -export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantContainerState> { +export class ZeroExInstantContainer extends React.Component<ZeroExInstantContainerProps, ZeroExInstantContainerState> { public state = { tokenSelectionPanelAnimationState: 'none' as SlideAnimationState, }; @@ -60,9 +59,9 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon </Flex> <SlidingPanel animationState={this.state.tokenSelectionPanelAnimationState} - onClose={this._handlePanelClose} + onClose={this._handlePanelCloseClickedX} > - <AvailableERC20TokenSelector onTokenSelect={this._handlePanelClose} /> + <AvailableERC20TokenSelector onTokenSelect={this._handlePanelCloseAfterChose} /> </SlidingPanel> <CurrentStandardSlidingPanel /> </Container> @@ -71,7 +70,7 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon marginTop="10px" marginLeft="auto" marginRight="auto" - width="140px" + width="108px" > <a href={ZERO_EX_SITE_URL} target="_blank"> <PoweredByLogo /> @@ -82,11 +81,19 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon ); } private readonly _handleSymbolClick = (): void => { + analytics.trackTokenSelectorOpened(); this.setState({ tokenSelectionPanelAnimationState: 'slidIn', }); }; - private readonly _handlePanelClose = (): void => { + private readonly _handlePanelCloseClickedX = (): void => { + this._handlePanelClose(TokenSelectorClosedVia.ClickedX); + }; + private readonly _handlePanelCloseAfterChose = (): void => { + this._handlePanelClose(TokenSelectorClosedVia.TokenChose); + }; + private readonly _handlePanelClose = (closedVia: TokenSelectorClosedVia): void => { + analytics.trackTokenSelectorClosed(closedVia); this.setState({ tokenSelectionPanelAnimationState: 'slidOut', }); diff --git a/packages/instant/src/components/zero_ex_instant_overlay.tsx b/packages/instant/src/components/zero_ex_instant_overlay.tsx index 2856ea3e3..96e560691 100644 --- a/packages/instant/src/components/zero_ex_instant_overlay.tsx +++ b/packages/instant/src/components/zero_ex_instant_overlay.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ZeroExInstantContainer } from '../components/zero_ex_instant_container'; +import { MAIN_CONTAINER_DIV_CLASS, OVERLAY_CLOSE_BUTTON_DIV_CLASS, OVERLAY_DIV_CLASS } from '../constants'; import { ColorOption } from '../style/theme'; import { Container } from './ui/container'; @@ -18,9 +19,15 @@ export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlay const { onClose, zIndex, ...rest } = props; return ( <ZeroExInstantProvider {...rest}> - <Overlay zIndex={zIndex}> + <Overlay zIndex={zIndex} className={OVERLAY_DIV_CLASS}> <Flex height="100vh"> - <Container position="absolute" top="0px" right="0px" display={{ default: 'initial', sm: 'none' }}> + <Container + className={OVERLAY_CLOSE_BUTTON_DIV_CLASS} + position="absolute" + top="0px" + right="0px" + display={{ default: 'initial', sm: 'none' }} + > <Icon height={18} width={18} @@ -30,7 +37,11 @@ export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlay padding="2em 2em" /> </Container> - <Container width={{ default: 'auto', sm: '100%' }} height={{ default: 'auto', sm: '100%' }}> + <Container + width={{ default: 'auto', sm: '100%' }} + height={{ default: 'auto', sm: '100%' }} + className={MAIN_CONTAINER_DIV_CLASS} + > <ZeroExInstantContainer /> </Container> </Flex> diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index fe34c4466..dae9124c6 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -11,10 +11,11 @@ import { asyncData } from '../redux/async_data'; import { DEFAULT_STATE, DefaultState, State } from '../redux/reducer'; import { store, Store } from '../redux/store'; import { fonts } from '../style/fonts'; -import { AccountState, AffiliateInfo, AssetMetaData, Network, OrderSource } from '../types'; +import { AccountState, AffiliateInfo, AssetMetaData, Network, OrderSource, QuoteFetchOrigin } from '../types'; import { analytics, disableAnalytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; +import { setupRollbar } from '../util/error_reporter'; import { gasPriceEstimator } from '../util/gas_price_estimator'; import { Heartbeater } from '../util/heartbeater'; import { generateAccountHeartbeater, generateBuyQuoteHeartbeater } from '../util/heartbeater_factory'; @@ -29,6 +30,7 @@ export interface ZeroExInstantProviderRequiredProps { export interface ZeroExInstantProviderOptionalProps { provider: Provider; + walletDisplayName: string; availableAssetDatas: string[]; defaultAssetBuyAmount: number; defaultSelectedAssetData: string; @@ -66,6 +68,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider ...defaultState, providerState, network: networkId, + walletDisplayName: props.walletDisplayName, selectedAsset: _.isUndefined(props.defaultSelectedAssetData) ? undefined : assetUtils.createAssetFromAssetDataOrThrow( @@ -86,6 +89,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider } constructor(props: ZeroExInstantProviderProps) { super(props); + setupRollbar(); fonts.include(); const initialAppState = ZeroExInstantProvider._mergeDefaultStateWithProps(this.props); this._store = store.create(initialAppState); @@ -115,7 +119,9 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider this._buyQuoteHeartbeat.start(BUY_QUOTE_UPDATE_INTERVAL_TIME_MS); // Trigger first buyquote fetch // tslint:disable-next-line:no-floating-promises - asyncData.fetchCurrentBuyQuoteAndDispatchToStore(state, dispatch, { updateSilently: false }); + asyncData.fetchCurrentBuyQuoteAndDispatchToStore(state, dispatch, QuoteFetchOrigin.Manual, { + updateSilently: false, + }); // warm up the gas price estimator cache just in case we can't // grab the gas price estimate when submitting the transaction // tslint:disable-next-line:no-floating-promises @@ -131,6 +137,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider this.props.orderSource, state.providerState, window, + state.selectedAsset, this.props.affiliateInfo, ), ); diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts index be6077ca9..506348092 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -7,19 +7,42 @@ export const ETH_DECIMALS = 18; export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer'; export const INJECTED_DIV_CLASS = 'zeroExInstantResetRoot'; export const INJECTED_DIV_ID = 'zeroExInstant'; +export const OVERLAY_DIV_CLASS = 'zeroExInstantOverlay'; +export const OVERLAY_CLOSE_BUTTON_DIV_CLASS = 'zeroExInstantOverlayCloseButton'; +export const MAIN_CONTAINER_DIV_CLASS = 'zeroExInstantMainContainer'; export const WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX = 'Transaction failed'; export const GWEI_IN_WEI = new BigNumber(1000000000); export const ONE_SECOND_MS = 1000; export const ONE_MINUTE_MS = ONE_SECOND_MS * 60; +export const GIT_SHA = process.env.GIT_SHA; +export const NPM_PACKAGE_VERSION = process.env.NPM_PACKAGE_VERSION; export const ACCOUNT_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 5; export const BUY_QUOTE_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 15; export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6); export const DEFAULT_ESTIMATED_TRANSACTION_TIME_MS = ONE_MINUTE_MS * 2; +export const MAGIC_TRIGGER_ERROR_INPUT = '0€'; +export const MAGIC_TRIGGER_ERROR_MESSAGE = 'Triggered error'; export const ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info'; export const HEAP_ANALYTICS_ID = process.env.HEAP_ANALYTICS_ID; +export const HEAP_ENABLED = process.env.HEAP_ENABLED; export const COINBASE_API_BASE_URL = 'https://api.coinbase.com/v2'; export const PROGRESS_STALL_AT_WIDTH = '95%'; export const PROGRESS_FINISH_ANIMATION_TIME_MS = 200; +export const HOST_DOMAINS = [ + '0x-instant-staging.s3-website-us-east-1.amazonaws.com', + '0x-instant-dogfood.s3-website-us-east-1.amazonaws.com', + 'localhost', + '127.0.0.1', + '0.0.0.0', + 'instant.0xproject.com', +]; +export const ROLLBAR_CLIENT_TOKEN = process.env.ROLLBAR_CLIENT_TOKEN; +export const ROLLBAR_ENABLED = process.env.ROLLBAR_ENABLED; +export const INSTANT_DISCHARGE_TARGET = process.env.INSTANT_DISCHARGE_TARGET as + | 'production' + | 'dogfood' + | 'staging' + | undefined; export const COINBASE_WALLET_IOS_APP_STORE_URL = 'https://itunes.apple.com/us/app/coinbase-wallet/id1278383455?mt=8'; export const COINBASE_WALLET_ANDROID_APP_STORE_URL = 'https://play.google.com/store/apps/details?id=org.toshi&hl=en'; export const COINBASE_WALLET_SITE_URL = 'https://wallet.coinbase.com/'; diff --git a/packages/instant/src/containers/connected_account_payment_method.ts b/packages/instant/src/containers/connected_account_payment_method.ts index cdeb49a25..bb68fdd57 100644 --- a/packages/instant/src/containers/connected_account_payment_method.ts +++ b/packages/instant/src/containers/connected_account_payment_method.ts @@ -11,7 +11,7 @@ import { import { Action, actions } from '../redux/actions'; import { asyncData } from '../redux/async_data'; import { State } from '../redux/reducer'; -import { Network, Omit, OperatingSystem, ProviderState, StandardSlidingPanelContent } from '../types'; +import { Network, Omit, OperatingSystem, ProviderState, StandardSlidingPanelContent, WalletSuggestion } from '../types'; import { analytics } from '../util/analytics'; import { envUtil } from '../util/env'; @@ -20,6 +20,7 @@ export interface ConnectedAccountPaymentMethodProps {} interface ConnectedState { network: Network; providerState: ProviderState; + walletDisplayName?: string; } interface ConnectedDispatch { @@ -34,6 +35,7 @@ type FinalProps = ConnectedProps & ConnectedAccountPaymentMethodProps; const mapStateToProps = (state: State, _ownProps: ConnectedAccountPaymentMethodProps): ConnectedState => ({ network: state.network, providerState: state.providerState, + walletDisplayName: state.walletDisplayName, }); const mapDispatchToProps = ( @@ -56,27 +58,32 @@ const mergeProps = ( ...ownProps, network: connectedState.network, account: connectedState.providerState.account, - walletName: connectedState.providerState.name, + walletDisplayName: connectedState.walletDisplayName || connectedState.providerState.name, onUnlockWalletClick: () => connectedDispatch.unlockWalletAndDispatchToStore(connectedState.providerState), onInstallWalletClick: () => { const isMobile = envUtil.isMobileOperatingSystem(); - if (!isMobile) { + const walletSuggestion: WalletSuggestion = isMobile + ? WalletSuggestion.CoinbaseWallet + : WalletSuggestion.MetaMask; + + analytics.trackInstallWalletClicked(walletSuggestion); + if (walletSuggestion === WalletSuggestion.MetaMask) { connectedDispatch.openInstallWalletPanel(); - return; - } - const operatingSystem = envUtil.getOperatingSystem(); - let url = COINBASE_WALLET_SITE_URL; - switch (operatingSystem) { - case OperatingSystem.Android: - url = COINBASE_WALLET_ANDROID_APP_STORE_URL; - break; - case OperatingSystem.iOS: - url = COINBASE_WALLET_IOS_APP_STORE_URL; - break; - default: - break; + } else { + const operatingSystem = envUtil.getOperatingSystem(); + let url = COINBASE_WALLET_SITE_URL; + switch (operatingSystem) { + case OperatingSystem.Android: + url = COINBASE_WALLET_ANDROID_APP_STORE_URL; + break; + case OperatingSystem.iOS: + url = COINBASE_WALLET_IOS_APP_STORE_URL; + break; + default: + break; + } + window.open(url, '_blank'); } - window.open(url, '_blank'); }, }); diff --git a/packages/instant/src/containers/latest_error.tsx b/packages/instant/src/containers/latest_error.tsx index b7cfdb504..0d4349124 100644 --- a/packages/instant/src/containers/latest_error.tsx +++ b/packages/instant/src/containers/latest_error.tsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { SlidingError } from '../components/sliding_error'; +import { Container } from '../components/ui/container'; import { Overlay } from '../components/ui/overlay'; import { Action } from '../redux/actions'; import { State } from '../redux/reducer'; @@ -23,7 +24,12 @@ export interface LatestErrorComponentProps { export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => { if (!props.latestErrorMessage) { - return <div />; + // Render a hidden SlidingError such that instant does not move when a real error is rendered. + return ( + <Container isHidden={true}> + <SlidingError animationState="slidIn" icon="😢" message="" /> + </Container> + ); } return ( <React.Fragment> diff --git a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts index 610335243..80943a96f 100644 --- a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts +++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts @@ -9,7 +9,8 @@ import { Dispatch } from 'redux'; import { BuyOrderStateButtons } from '../components/buy_order_state_buttons'; import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; -import { AccountState, AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; +import { AccountState, AffiliateInfo, Asset, OrderProcessState, ZeroExInstantError } from '../types'; +import { analytics } from '../util/analytics'; import { errorFlasher } from '../util/error_flasher'; import { etherscanUtil } from '../util/etherscan'; @@ -21,6 +22,7 @@ interface ConnectedState { assetBuyer: AssetBuyer; web3Wrapper: Web3Wrapper; affiliateInfo?: AffiliateInfo; + selectedAsset?: Asset; onViewTransaction: () => void; } @@ -40,6 +42,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButt const account = state.providerState.account; const accountAddress = account.state === AccountState.Ready ? account.address : undefined; const accountEthBalanceInWei = account.state === AccountState.Ready ? account.ethBalanceInWei : undefined; + const selectedAsset = state.selectedAsset; return { accountAddress, accountEthBalanceInWei, @@ -48,6 +51,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButt web3Wrapper, buyQuote: state.latestBuyQuote, affiliateInfo: state.affiliateInfo, + selectedAsset, onViewTransaction: () => { if ( state.buyOrderState.processState === OrderProcessState.Processing || @@ -59,6 +63,8 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButt assetBuyer.networkId, ); if (etherscanUrl) { + analytics.trackTransactionViewed(state.buyOrderState.processState); + window.open(etherscanUrl, '_blank'); return; } diff --git a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts index a39bc46a2..cb9df527e 100644 --- a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -10,7 +10,7 @@ import { ERC20AssetAmountInput, ERC20AssetAmountInputProps } from '../components import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; import { ColorOption } from '../style/theme'; -import { AffiliateInfo, ERC20Asset, Omit, OrderProcessState } from '../types'; +import { AffiliateInfo, ERC20Asset, Omit, OrderProcessState, QuoteFetchOrigin } from '../types'; import { buyQuoteUpdater } from '../util/buy_quote_updater'; export interface SelectedERC20AssetAmountInputProps { @@ -88,7 +88,7 @@ const mapDispatchToProps = ( // even if it's debounced, give them the illusion it's loading dispatch(actions.setQuoteRequestStatePending()); // tslint:disable-next-line:no-floating-promises - debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value, { + debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value, QuoteFetchOrigin.Manual, { setPending: true, dispatchErrors: true, affiliateInfo, diff --git a/packages/instant/src/data/asset_meta_data_map.ts b/packages/instant/src/data/asset_meta_data_map.ts index b24c9c83d..88611a8c0 100644 --- a/packages/instant/src/data/asset_meta_data_map.ts +++ b/packages/instant/src/data/asset_meta_data_map.ts @@ -83,14 +83,14 @@ export const assetMetaDataMap: ObjectMap<AssetMetaData> = { '0xf47261b0000000000000000000000000e0b7927c4af23765cb51314a0e0521a9645f0e2a': { assetProxyId: AssetProxyId.ERC20, decimals: 9, - primaryColor: '#DEB564', + primaryColor: '#E1AA3E', symbol: 'dgd', name: 'DigixDao', }, '0xf47261b00000000000000000000000004f3afec4e5a3f2a6a1a411def7d7dfe50ee057bf': { assetProxyId: AssetProxyId.ERC20, decimals: 9, - primaryColor: '#DEB564', + primaryColor: '#E1AA3E', symbol: 'dgx', name: 'Digix Gold Token', }, @@ -195,7 +195,7 @@ export const assetMetaDataMap: ObjectMap<AssetMetaData> = { '0xf47261b000000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359': { assetProxyId: AssetProxyId.ERC20, decimals: 18, - primaryColor: '#F2B350', + primaryColor: '#DEA349', symbol: 'dai', name: 'Dai Stablecoin', }, diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index 3a8694d6a..d172f4145 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -2,8 +2,15 @@ import * as _ from 'lodash'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR, INJECTED_DIV_CLASS, INJECTED_DIV_ID } from './constants'; +import { + DEFAULT_ZERO_EX_CONTAINER_SELECTOR, + GIT_SHA as GIT_SHA_FROM_CONSTANT, + INJECTED_DIV_CLASS, + INJECTED_DIV_ID, + NPM_PACKAGE_VERSION, +} from './constants'; import { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './index'; +import { analytics } from './util/analytics'; import { assert } from './util/assert'; import { util } from './util/util'; @@ -38,6 +45,9 @@ const validateInstantRenderConfig = (config: ZeroExInstantConfig, selector: stri if (!_.isUndefined(config.provider)) { assert.isWeb3Provider('provider', config.provider); } + if (!_.isUndefined(config.walletDisplayName)) { + assert.isString('walletDisplayName', config.walletDisplayName); + } if (!_.isUndefined(config.shouldDisablePushToHistory)) { assert.isBoolean('shouldDisablePushToHistory', config.shouldDisablePushToHistory); } @@ -57,6 +67,7 @@ const renderInstant = (config: ZeroExInstantConfig, selector: string) => { injectedDiv.setAttribute('class', INJECTED_DIV_CLASS); appendTo.appendChild(injectedDiv); const closeInstant = () => { + analytics.trackInstantClosed(); if (!_.isUndefined(config.onClose)) { config.onClose(); } @@ -89,12 +100,12 @@ export const render = (config: ZeroExInstantConfig, selector: string = DEFAULT_Z // If the integrator defined a popstate handler, save it to __zeroExInstantIntegratorsPopStateHandler // unless we have already done so on a previous render. const anyWindow = window as any; - if (window.onpopstate && !anyWindow.__zeroExInstantIntegratorsPopStateHandler) { - anyWindow.__zeroExInstantIntegratorsPopStateHandler = window.onpopstate.bind(window); - } - const integratorsOnPopStateHandler = anyWindow.__zeroExInstantIntegratorsPopStateHandler || util.boundNoop; + const popStateExistsAndNotSetPreviously = window.onpopstate && !anyWindow.__zeroExInstantIntegratorsPopStateHandler; + anyWindow.__zeroExInstantIntegratorsPopStateHandler = popStateExistsAndNotSetPreviously + ? anyWindow.onpopstate.bind(window) + : util.boundNoop; const onPopStateHandler = (e: PopStateEvent) => { - integratorsOnPopStateHandler(e); + anyWindow.__zeroExInstantIntegratorsPopStateHandler(e); const newState = e.state; if (newState && newState.zeroExInstantShowing) { // We have returned to a history state that expects instant to be rendered. @@ -110,3 +121,7 @@ export const render = (config: ZeroExInstantConfig, selector: string = DEFAULT_Z }; window.onpopstate = onPopStateHandler; }; + +// Write version info to the exported object for debugging +export const GIT_SHA = GIT_SHA_FROM_CONSTANT; +export const NPM_VERSION = NPM_PACKAGE_VERSION; diff --git a/packages/instant/src/redux/analytics_middleware.ts b/packages/instant/src/redux/analytics_middleware.ts index 299c2560e..3f7a51707 100644 --- a/packages/instant/src/redux/analytics_middleware.ts +++ b/packages/instant/src/redux/analytics_middleware.ts @@ -1,10 +1,11 @@ +import { AssetProxyId } from '@0x/types'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; import { Middleware } from 'redux'; import { ETH_DECIMALS } from '../constants'; -import { Account, AccountState } from '../types'; -import { analytics } from '../util/analytics'; +import { AccountState, StandardSlidingPanelContent } from '../types'; +import { analytics, AnalyticsEventOptions } from '../util/analytics'; import { Action, ActionTypes } from './actions'; @@ -29,9 +30,11 @@ export const analyticsMiddleware: Middleware = store => next => middlewareAction if (didJustTurnReady) { analytics.trackAccountReady(ethAddress); analytics.addUserProperties({ lastKnownEthAddress: ethAddress }); + analytics.addEventProperties({ ethAddress }); } else if (didJustUpdateAddress) { analytics.trackAccountAddressChanged(ethAddress); analytics.addUserProperties({ lastKnownEthAddress: ethAddress }); + analytics.addEventProperties({ ethAddress }); } } break; @@ -51,8 +54,51 @@ export const analyticsMiddleware: Middleware = store => next => middlewareAction curAccount.ethBalanceInWei, ETH_DECIMALS, ).toString(); - analytics.addUserProperties({ ethBalanceInUnitAmount }); + analytics.addUserProperties({ lastEthBalanceInUnitAmount: ethBalanceInUnitAmount }); + analytics.addEventProperties({ ethBalanceInUnitAmount }); } + break; + case ActionTypes.UPDATE_SELECTED_ASSET: + const selectedAsset = curState.selectedAsset; + if (selectedAsset) { + const assetName = selectedAsset.metaData.name; + const assetData = selectedAsset.assetData; + analytics.trackTokenSelectorChose({ + assetName, + assetData, + }); + + const selectedAssetEventProperties: AnalyticsEventOptions = { + selectedAssetName: assetName, + selectedAssetData: assetData, + }; + if (selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20) { + selectedAssetEventProperties.selectedAssetDecimals = selectedAsset.metaData.decimals; + selectedAssetEventProperties.selectedAssetSymbol = selectedAsset.metaData.symbol; + } + analytics.addEventProperties(selectedAssetEventProperties); + } + break; + case ActionTypes.SET_AVAILABLE_ASSETS: + const availableAssets = curState.availableAssets; + if (availableAssets) { + analytics.addEventProperties({ + numberAvailableAssets: availableAssets.length, + }); + } + break; + case ActionTypes.OPEN_STANDARD_SLIDING_PANEL: + const openSlidingContent = curState.standardSlidingPanelSettings.content; + if (openSlidingContent === StandardSlidingPanelContent.InstallWallet) { + analytics.trackInstallWalletModalOpened(); + } + break; + case ActionTypes.CLOSE_STANDARD_SLIDING_PANEL: + const closeSlidingContent = curState.standardSlidingPanelSettings.content; + if (closeSlidingContent === StandardSlidingPanelContent.InstallWallet) { + analytics.trackInstallWalletModalClosed(); + } + break; } return nextAction; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts index 6feb760e7..c67b222d1 100644 --- a/packages/instant/src/redux/async_data.ts +++ b/packages/instant/src/redux/async_data.ts @@ -4,12 +4,13 @@ import * as _ from 'lodash'; import { Dispatch } from 'redux'; import { BIG_NUMBER_ZERO } from '../constants'; -import { AccountState, ERC20Asset, OrderProcessState, ProviderState } from '../types'; +import { AccountState, ERC20Asset, OrderProcessState, ProviderState, QuoteFetchOrigin } from '../types'; import { analytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { buyQuoteUpdater } from '../util/buy_quote_updater'; import { coinbaseApi } from '../util/coinbase_api'; import { errorFlasher } from '../util/error_flasher'; +import { errorReporter } from '../util/error_reporter'; import { actions } from './actions'; import { State } from './reducer'; @@ -23,6 +24,7 @@ export const asyncData = { const errorMessage = 'Error fetching ETH/USD price'; errorFlasher.flashNewErrorMessage(dispatch, errorMessage); dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + errorReporter.report(e); } }, fetchAvailableAssetDatasAndDispatchToStore: async (state: State, dispatch: Dispatch) => { @@ -30,13 +32,15 @@ export const asyncData = { const assetBuyer = providerState.assetBuyer; try { const assetDatas = await assetBuyer.getAvailableAssetDatasAsync(); - const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, assetMetaDataMap, network); + const deduplicatedAssetDatas = _.uniq(assetDatas); + const assets = assetUtils.createAssetsFromAssetDatas(deduplicatedAssetDatas, assetMetaDataMap, network); dispatch(actions.setAvailableAssets(assets)); } catch (e) { const errorMessage = 'Could not find any assets'; errorFlasher.flashNewErrorMessage(dispatch, errorMessage); // On error, just specify that none are available dispatch(actions.setAvailableAssets([])); + errorReporter.report(e); } }, fetchAccountInfoAndDispatchToStore: async ( @@ -77,6 +81,7 @@ export const asyncData = { const ethBalanceInWei = await web3Wrapper.getBalanceInWeiAsync(address); dispatch(actions.updateAccountEthBalance({ address, ethBalanceInWei })); } catch (e) { + errorReporter.report(e); // leave balance as is return; } @@ -84,6 +89,7 @@ export const asyncData = { fetchCurrentBuyQuoteAndDispatchToStore: async ( state: State, dispatch: Dispatch, + fetchOrigin: QuoteFetchOrigin, options: { updateSilently: boolean }, ) => { const { buyOrderState, providerState, selectedAsset, selectedAssetUnitAmount, affiliateInfo } = state; @@ -99,7 +105,12 @@ export const asyncData = { dispatch, selectedAsset as ERC20Asset, selectedAssetUnitAmount, - { setPending: !options.updateSilently, dispatchErrors: !options.updateSilently, affiliateInfo }, + fetchOrigin, + { + setPending: !options.updateSilently, + dispatchErrors: !options.updateSilently, + affiliateInfo, + }, ); } }, diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index dfc2b89f3..a9a407b7d 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -49,6 +49,7 @@ interface OptionalState { latestBuyQuote: BuyQuote; latestErrorMessage: string; affiliateInfo: AffiliateInfo; + walletDisplayName: string; } export type State = DefaultState & PropsDerivedState & Partial<OptionalState>; diff --git a/packages/instant/src/style/theme.ts b/packages/instant/src/style/theme.ts index a0751286b..fd3f03c3f 100644 --- a/packages/instant/src/style/theme.ts +++ b/packages/instant/src/style/theme.ts @@ -1,6 +1,14 @@ import * as styledComponents from 'styled-components'; -const { default: styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider } = styledComponents; +const { + default: styled, + css, + keyframes, + withTheme, + createGlobalStyle, + ThemeConsumer, + ThemeProvider, +} = styledComponents; export type Theme = { [key in ColorOption]: string }; @@ -30,8 +38,8 @@ export const theme: Theme = { lightestGrey: '#EEEEEE', darkGrey: '#333333', white: 'white', - lightOrange: '#F9F2ED', - darkOrange: '#F2994C', + lightOrange: '#FFF8F2', + darkOrange: '#F7A24F', green: '#3CB34F', red: '#D00000', darkBlue: '#135df6', @@ -45,4 +53,4 @@ export const generateOverlayBlack = (opacity = 0.6) => { return `rgba(0, 0, 0, ${opacity})`; }; -export { styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider }; +export { styled, css, keyframes, withTheme, createGlobalStyle, ThemeConsumer, ThemeProvider }; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 999d50fed..2d73ba29e 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -21,6 +21,11 @@ export enum OrderProcessState { Failure = 'FAILURE', } +export enum QuoteFetchOrigin { + Manual = 'Manual', + Heartbeat = 'Heartbeat', +} + export interface SimulatedProgress { startTimeUnix: number; expectedEndTimeUnix: number; @@ -149,6 +154,11 @@ export enum Browser { Other = 'OTHER', } +export enum WalletSuggestion { + CoinbaseWallet = 'Coinbase Wallet', + MetaMask = 'MetaMask', +} + export enum OperatingSystem { Android = 'ANDROID', iOS = 'IOS', diff --git a/packages/instant/src/util/analytics.ts b/packages/instant/src/util/analytics.ts index cec99dd1b..6da37bedb 100644 --- a/packages/instant/src/util/analytics.ts +++ b/packages/instant/src/util/analytics.ts @@ -1,26 +1,66 @@ -import { AffiliateInfo, Network, OrderSource, ProviderState } from '../types'; +import { BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { GIT_SHA, HEAP_ENABLED, INSTANT_DISCHARGE_TARGET, NPM_PACKAGE_VERSION } from '../constants'; +import { + AffiliateInfo, + Asset, + Network, + OrderProcessState, + OrderSource, + ProviderState, + QuoteFetchOrigin, + WalletSuggestion, +} from '../types'; import { EventProperties, heapUtil } from './heap'; -let isDisabled = false; +let isDisabledViaConfig = false; export const disableAnalytics = (shouldDisableAnalytics: boolean) => { - isDisabled = shouldDisableAnalytics; + isDisabledViaConfig = shouldDisableAnalytics; }; export const evaluateIfEnabled = (fnCall: () => void) => { - if (isDisabled) { + if (isDisabledViaConfig) { return; } - fnCall(); + if (HEAP_ENABLED) { + fnCall(); + } }; enum EventNames { INSTANT_OPENED = 'Instant - Opened', + INSTANT_CLOSED = 'Instant - Closed', ACCOUNT_LOCKED = 'Account - Locked', ACCOUNT_READY = 'Account - Ready', ACCOUNT_UNLOCK_REQUESTED = 'Account - Unlock Requested', ACCOUNT_UNLOCK_DENIED = 'Account - Unlock Denied', ACCOUNT_ADDRESS_CHANGED = 'Account - Address Changed', + PAYMENT_METHOD_DROPDOWN_OPENED = 'Payment Method - Dropdown Opened', + PAYMENT_METHOD_OPENED_ETHERSCAN = 'Payment Method - Opened Etherscan', + PAYMENT_METHOD_COPIED_ADDRESS = 'Payment Method - Copied Address', + BUY_NOT_ENOUGH_ETH = 'Buy - Not Enough Eth', + BUY_STARTED = 'Buy - Started', + BUY_SIGNATURE_DENIED = 'Buy - Signature Denied', + BUY_SIMULATION_FAILED = 'Buy - Simulation Failed', + BUY_TX_SUBMITTED = 'Buy - Tx Submitted', + BUY_TX_SUCCEEDED = 'Buy - Tx Succeeded', + BUY_TX_FAILED = 'Buy - Tx Failed', + INSTALL_WALLET_CLICKED = 'Install Wallet - Clicked', + INSTALL_WALLET_MODAL_OPENED = 'Install Wallet - Modal - Opened', + INSTALL_WALLET_MODAL_CLICKED_EXPLANATION = 'Install Wallet - Modal - Clicked Explanation', + INSTALL_WALLET_MODAL_CLICKED_GET = 'Install Wallet - Modal - Clicked Get', + INSTALL_WALLET_MODAL_CLOSED = 'Install Wallet - Modal - Closed', + TOKEN_SELECTOR_OPENED = 'Token Selector - Opened', + TOKEN_SELECTOR_CLOSED = 'Token Selector - Closed', + TOKEN_SELECTOR_CHOSE = 'Token Selector - Chose', + TOKEN_SELECTOR_SEARCHED = 'Token Selector - Searched', + TRANSACTION_VIEWED = 'Transaction - Viewed', + QUOTE_FETCHED = 'Quote - Fetched', + QUOTE_ERROR = 'Quote - Error', } + const track = (eventName: EventNames, eventProperties: EventProperties = {}): void => { evaluateIfEnabled(() => { heapUtil.evaluateHeapCall(heap => heap.track(eventName, eventProperties)); @@ -38,22 +78,50 @@ function trackingEventFnWithPayload(eventName: EventNames): (eventProperties: Ev }; } +const buyQuoteEventProperties = (buyQuote: BuyQuote) => { + const assetBuyAmount = buyQuote.assetBuyAmount.toString(); + const assetEthAmount = buyQuote.worstCaseQuoteInfo.assetEthAmount.toString(); + const feeEthAmount = buyQuote.worstCaseQuoteInfo.feeEthAmount.toString(); + const totalEthAmount = buyQuote.worstCaseQuoteInfo.totalEthAmount.toString(); + const feePercentage = !_.isUndefined(buyQuote.feePercentage) ? buyQuote.feePercentage.toString() : 0; + const hasFeeOrders = !_.isEmpty(buyQuote.feeOrders) ? 'true' : 'false'; + return { + assetBuyAmount, + assetEthAmount, + feeEthAmount, + totalEthAmount, + feePercentage, + hasFeeOrders, + }; +}; + export interface AnalyticsUserOptions { lastKnownEthAddress?: string; - ethBalanceInUnitAmount?: string; + lastEthBalanceInUnitAmount?: string; } export interface AnalyticsEventOptions { embeddedHost?: string; embeddedUrl?: string; + ethBalanceInUnitAmount?: string; + ethAddress?: string; networkId?: number; providerName?: string; gitSha?: string; npmVersion?: string; + instantEnvironment?: string; orderSource?: string; affiliateAddress?: string; affiliateFeePercent?: number; + numberAvailableAssets?: number; + selectedAssetName?: string; + selectedAssetSymbol?: string; + selectedAssetData?: string; + selectedAssetDecimals?: number; +} +export enum TokenSelectorClosedVia { + ClickedX = 'Clicked X', + TokenChose = 'Token Chose', } - export const analytics = { addUserProperties: (properties: AnalyticsUserOptions): void => { evaluateIfEnabled(() => { @@ -70,28 +138,94 @@ export const analytics = { orderSource: OrderSource, providerState: ProviderState, window: Window, + selectedAsset?: Asset, affiliateInfo?: AffiliateInfo, ): AnalyticsEventOptions => { const affiliateAddress = affiliateInfo ? affiliateInfo.feeRecipient : 'none'; const affiliateFeePercent = affiliateInfo ? parseFloat(affiliateInfo.feePercentage.toFixed(4)) : 0; const orderSourceName = typeof orderSource === 'string' ? orderSource : 'provided'; - return { + const eventOptions: AnalyticsEventOptions = { embeddedHost: window.location.host, embeddedUrl: window.location.href, networkId: network, providerName: providerState.name, - gitSha: process.env.GIT_SHA, - npmVersion: process.env.NPM_PACKAGE_VERSION, + gitSha: GIT_SHA, + npmVersion: NPM_PACKAGE_VERSION, orderSource: orderSourceName, affiliateAddress, affiliateFeePercent, + selectedAssetName: selectedAsset ? selectedAsset.metaData.name : 'none', + selectedAssetData: selectedAsset ? selectedAsset.assetData : 'none', + instantEnvironment: INSTANT_DISCHARGE_TARGET || `Local ${process.env.NODE_ENV}`, }; + return eventOptions; }, trackInstantOpened: trackingEventFnWithoutPayload(EventNames.INSTANT_OPENED), + trackInstantClosed: trackingEventFnWithoutPayload(EventNames.INSTANT_CLOSED), trackAccountLocked: trackingEventFnWithoutPayload(EventNames.ACCOUNT_LOCKED), trackAccountReady: (address: string) => trackingEventFnWithPayload(EventNames.ACCOUNT_READY)({ address }), trackAccountUnlockRequested: trackingEventFnWithoutPayload(EventNames.ACCOUNT_UNLOCK_REQUESTED), trackAccountUnlockDenied: trackingEventFnWithoutPayload(EventNames.ACCOUNT_UNLOCK_DENIED), trackAccountAddressChanged: (address: string) => trackingEventFnWithPayload(EventNames.ACCOUNT_ADDRESS_CHANGED)({ address }), + trackPaymentMethodDropdownOpened: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_DROPDOWN_OPENED), + trackPaymentMethodOpenedEtherscan: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_OPENED_ETHERSCAN), + trackPaymentMethodCopiedAddress: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_COPIED_ADDRESS), + trackBuyNotEnoughEth: (buyQuote: BuyQuote) => + trackingEventFnWithPayload(EventNames.BUY_NOT_ENOUGH_ETH)(buyQuoteEventProperties(buyQuote)), + trackBuyStarted: (buyQuote: BuyQuote) => + trackingEventFnWithPayload(EventNames.BUY_STARTED)(buyQuoteEventProperties(buyQuote)), + trackBuySignatureDenied: (buyQuote: BuyQuote) => + trackingEventFnWithPayload(EventNames.BUY_SIGNATURE_DENIED)(buyQuoteEventProperties(buyQuote)), + trackBuySimulationFailed: (buyQuote: BuyQuote) => + trackingEventFnWithPayload(EventNames.BUY_SIMULATION_FAILED)(buyQuoteEventProperties(buyQuote)), + trackBuyTxSubmitted: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => + trackingEventFnWithPayload(EventNames.BUY_TX_SUBMITTED)({ + ...buyQuoteEventProperties(buyQuote), + txHash, + expectedTxTimeMs: expectedEndTimeUnix - startTimeUnix, + }), + trackBuyTxSucceeded: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => + trackingEventFnWithPayload(EventNames.BUY_TX_SUCCEEDED)({ + ...buyQuoteEventProperties(buyQuote), + txHash, + expectedTxTimeMs: expectedEndTimeUnix - startTimeUnix, + actualTxTimeMs: new Date().getTime() - startTimeUnix, + }), + trackBuyTxFailed: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => + trackingEventFnWithPayload(EventNames.BUY_TX_FAILED)({ + ...buyQuoteEventProperties(buyQuote), + txHash, + expectedTxTimeMs: expectedEndTimeUnix - startTimeUnix, + actualTxTimeMs: new Date().getTime() - startTimeUnix, + }), + trackInstallWalletClicked: (walletSuggestion: WalletSuggestion) => + trackingEventFnWithPayload(EventNames.INSTALL_WALLET_CLICKED)({ walletSuggestion }), + trackInstallWalletModalClickedExplanation: trackingEventFnWithoutPayload( + EventNames.INSTALL_WALLET_MODAL_CLICKED_EXPLANATION, + ), + trackInstallWalletModalClickedGet: trackingEventFnWithoutPayload(EventNames.INSTALL_WALLET_MODAL_CLICKED_GET), + trackInstallWalletModalOpened: trackingEventFnWithoutPayload(EventNames.INSTALL_WALLET_MODAL_OPENED), + trackInstallWalletModalClosed: trackingEventFnWithoutPayload(EventNames.INSTALL_WALLET_MODAL_CLOSED), + trackTokenSelectorOpened: trackingEventFnWithoutPayload(EventNames.TOKEN_SELECTOR_OPENED), + trackTokenSelectorClosed: (closedVia: TokenSelectorClosedVia) => + trackingEventFnWithPayload(EventNames.TOKEN_SELECTOR_CLOSED)({ closedVia }), + trackTokenSelectorChose: (payload: { assetName: string; assetData: string }) => + trackingEventFnWithPayload(EventNames.TOKEN_SELECTOR_CHOSE)(payload), + trackTokenSelectorSearched: (searchText: string) => + trackingEventFnWithPayload(EventNames.TOKEN_SELECTOR_SEARCHED)({ searchText }), + trackTransactionViewed: (orderProcesState: OrderProcessState) => + trackingEventFnWithPayload(EventNames.TRANSACTION_VIEWED)({ orderState: orderProcesState }), + trackQuoteFetched: (buyQuote: BuyQuote, fetchOrigin: QuoteFetchOrigin) => + trackingEventFnWithPayload(EventNames.QUOTE_FETCHED)({ + ...buyQuoteEventProperties(buyQuote), + fetchOrigin, + }), + trackQuoteError: (errorMessage: string, assetBuyAmount: BigNumber, fetchOrigin: QuoteFetchOrigin) => { + trackingEventFnWithPayload(EventNames.QUOTE_ERROR)({ + errorMessage, + assetBuyAmount: assetBuyAmount.toString(), + fetchOrigin, + }); + }, }; diff --git a/packages/instant/src/util/asset.ts b/packages/instant/src/util/asset.ts index 40560d3eb..08f3642e3 100644 --- a/packages/instant/src/util/asset.ts +++ b/packages/instant/src/util/asset.ts @@ -1,3 +1,4 @@ +import { AssetBuyerError } from '@0x/asset-buyer'; import { AssetProxyId, ObjectMap } from '@0x/types'; import * as _ from 'lodash'; @@ -106,4 +107,20 @@ export const assetUtils = { ); return _.compact(erc20sOrUndefined); }, + assetBuyerErrorMessage: (asset: ERC20Asset, error: Error): string | undefined => { + if (error.message === AssetBuyerError.InsufficientAssetLiquidity) { + const assetName = assetUtils.bestNameForAsset(asset, 'of this asset'); + return `Not enough ${assetName} available`; + } else if (error.message === AssetBuyerError.InsufficientZrxLiquidity) { + return 'Not enough ZRX available'; + } else if ( + error.message === AssetBuyerError.StandardRelayerApiError || + error.message.startsWith(AssetBuyerError.AssetUnavailable) + ) { + const assetName = assetUtils.bestNameForAsset(asset, 'This asset'); + return `${assetName} is currently unavailable`; + } + + return undefined; + }, }; diff --git a/packages/instant/src/util/buy_quote_updater.ts b/packages/instant/src/util/buy_quote_updater.ts index 2fd16d781..4229f2735 100644 --- a/packages/instant/src/util/buy_quote_updater.ts +++ b/packages/instant/src/util/buy_quote_updater.ts @@ -1,4 +1,4 @@ -import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { AssetBuyer, BuyQuote } from '@0x/asset-buyer'; import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; @@ -6,9 +6,11 @@ import { Dispatch } from 'redux'; import { oc } from 'ts-optchain'; import { Action, actions } from '../redux/actions'; -import { AffiliateInfo, ERC20Asset } from '../types'; +import { AffiliateInfo, ERC20Asset, QuoteFetchOrigin } from '../types'; +import { analytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; +import { errorReporter } from '../util/error_reporter'; export const buyQuoteUpdater = { updateBuyQuoteAsync: async ( @@ -16,7 +18,12 @@ export const buyQuoteUpdater = { dispatch: Dispatch<Action>, asset: ERC20Asset, assetUnitAmount: BigNumber, - options: { setPending: boolean; dispatchErrors: boolean; affiliateInfo?: AffiliateInfo }, + fetchOrigin: QuoteFetchOrigin, + options: { + setPending: boolean; + dispatchErrors: boolean; + affiliateInfo?: AffiliateInfo; + }, ): Promise<void> => { // get a new buy quote. const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetUnitAmount, asset.metaData.decimals); @@ -29,34 +36,24 @@ export const buyQuoteUpdater = { try { newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue, { feePercentage }); } catch (error) { + const errorMessage = assetUtils.assetBuyerErrorMessage(asset, error); + + if (_.isUndefined(errorMessage)) { + // This is an unknown error, report it to rollbar + errorReporter.report(error); + } + if (options.dispatchErrors) { dispatch(actions.setQuoteRequestStateFailure()); - let errorMessage; - if (error.message === AssetBuyerError.InsufficientAssetLiquidity) { - const assetName = assetUtils.bestNameForAsset(asset, 'of this asset'); - errorMessage = `Not enough ${assetName} available`; - } else if (error.message === AssetBuyerError.InsufficientZrxLiquidity) { - errorMessage = 'Not enough ZRX available'; - } else if ( - error.message === AssetBuyerError.StandardRelayerApiError || - error.message.startsWith(AssetBuyerError.AssetUnavailable) - ) { - const assetName = assetUtils.bestNameForAsset(asset, 'This asset'); - errorMessage = `${assetName} is currently unavailable`; - } - if (!_.isUndefined(errorMessage)) { - errorFlasher.flashNewErrorMessage(dispatch, errorMessage); - } else { - throw error; - } + analytics.trackQuoteError(error.message ? error.message : 'other', baseUnitValue, fetchOrigin); + errorFlasher.flashNewErrorMessage(dispatch, errorMessage || 'Error fetching price, please try again'); } - // TODO: report to error reporter on else - return; } // We have a successful new buy quote errorFlasher.clearError(dispatch); // invalidate the last buy quote. dispatch(actions.updateLatestBuyQuote(newBuyQuote)); + analytics.trackQuoteFetched(newBuyQuote, fetchOrigin); }, }; diff --git a/packages/instant/src/util/error_reporter.ts b/packages/instant/src/util/error_reporter.ts new file mode 100644 index 000000000..b1824eaf9 --- /dev/null +++ b/packages/instant/src/util/error_reporter.ts @@ -0,0 +1,62 @@ +import { logUtils } from '@0x/utils'; +import * as _ from 'lodash'; + +import { GIT_SHA, HOST_DOMAINS, INSTANT_DISCHARGE_TARGET, ROLLBAR_CLIENT_TOKEN, ROLLBAR_ENABLED } from '../constants'; + +// Import version of Rollbar designed for embedded components +// See https://docs.rollbar.com/docs/using-rollbarjs-inside-an-embedded-component +// tslint:disable-next-line:no-var-requires +const Rollbar = require('rollbar/dist/rollbar.noconflict.umd'); + +let rollbar: any; +// Configures rollbar and sets up error catching +export const setupRollbar = (): any => { + if (_.isUndefined(rollbar) && ROLLBAR_CLIENT_TOKEN && ROLLBAR_ENABLED) { + rollbar = new Rollbar({ + accessToken: ROLLBAR_CLIENT_TOKEN, + captureUncaught: true, + captureUnhandledRejections: true, + enabled: true, + itemsPerMinute: 10, + maxItems: 500, + payload: { + environment: INSTANT_DISCHARGE_TARGET || `Local ${process.env.NODE_ENV}`, + client: { + javascript: { + source_map_enabled: true, + code_version: GIT_SHA, + guess_uncaught_frames: true, + }, + }, + }, + hostWhiteList: HOST_DOMAINS, + uncaughtErrorLevel: 'error', + 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)', + ], + }); + } +}; + +export const errorReporter = { + report(err: Error): void { + if (!rollbar) { + logUtils.log('Not reporting to rollbar because not configured', err); + return; + } + + rollbar.error(err, (rollbarErr: Error) => { + if (rollbarErr) { + logUtils.log(`Error reporting to rollbar, ignoring: ${rollbarErr}`); + } + }); + }, +}; diff --git a/packages/instant/src/util/gas_price_estimator.ts b/packages/instant/src/util/gas_price_estimator.ts index 6b15809a3..332c8d00a 100644 --- a/packages/instant/src/util/gas_price_estimator.ts +++ b/packages/instant/src/util/gas_price_estimator.ts @@ -7,6 +7,8 @@ import { GWEI_IN_WEI, } from '../constants'; +import { errorReporter } from './error_reporter'; + interface EthGasStationResult { average: number; fastestWait: number; @@ -42,8 +44,9 @@ export class GasPriceEstimator { let fetchedAmount: GasInfo | undefined; try { fetchedAmount = await fetchFastAmountInWeiAsync(); - } catch { + } catch (e) { fetchedAmount = undefined; + errorReporter.report(e); } if (fetchedAmount) { diff --git a/packages/instant/src/util/heap.ts b/packages/instant/src/util/heap.ts index 7c53c9918..279ff3059 100644 --- a/packages/instant/src/util/heap.ts +++ b/packages/instant/src/util/heap.ts @@ -5,6 +5,7 @@ import * as _ from 'lodash'; import { HEAP_ANALYTICS_ID } from '../constants'; import { AnalyticsEventOptions, AnalyticsUserOptions } from './analytics'; +import { errorReporter } from './error_reporter'; export type EventProperties = ObjectMap<string | number>; @@ -107,8 +108,8 @@ export const heapUtil = { heapFunctionCall(curHeap); } catch (e) { // We never want analytics to crash our React component - // TODO(sk): error reporter here logUtils.log('Analytics error', e); + errorReporter.report(e); } } }, diff --git a/packages/instant/src/util/heartbeater_factory.ts b/packages/instant/src/util/heartbeater_factory.ts index 2b852fb0d..cf29bf3ea 100644 --- a/packages/instant/src/util/heartbeater_factory.ts +++ b/packages/instant/src/util/heartbeater_factory.ts @@ -1,5 +1,6 @@ import { asyncData } from '../redux/async_data'; import { Store } from '../redux/store'; +import { QuoteFetchOrigin } from '../types'; import { Heartbeater } from './heartbeater'; @@ -17,8 +18,13 @@ export const generateAccountHeartbeater = (options: HeartbeatFactoryOptions): He export const generateBuyQuoteHeartbeater = (options: HeartbeatFactoryOptions): Heartbeater => { const { store, shouldPerformImmediatelyOnStart } = options; return new Heartbeater(async () => { - await asyncData.fetchCurrentBuyQuoteAndDispatchToStore(store.getState(), store.dispatch, { - updateSilently: true, - }); + await asyncData.fetchCurrentBuyQuoteAndDispatchToStore( + store.getState(), + store.dispatch, + QuoteFetchOrigin.Heartbeat, + { + updateSilently: true, + }, + ); }, shouldPerformImmediatelyOnStart); }; diff --git a/packages/instant/test/util/asset.test.ts b/packages/instant/test/util/asset.test.ts index 4229b24ed..fc4e4e2e4 100644 --- a/packages/instant/test/util/asset.test.ts +++ b/packages/instant/test/util/asset.test.ts @@ -1,6 +1,7 @@ +import { AssetBuyerError } from '@0x/asset-buyer'; import { AssetProxyId, ObjectMap } from '@0x/types'; -import { Asset, AssetMetaData, ERC20AssetMetaData, Network, ZeroExInstantError } from '../../src/types'; +import { Asset, AssetMetaData, ERC20Asset, ERC20AssetMetaData, Network, ZeroExInstantError } from '../../src/types'; import { assetUtils } from '../../src/util/asset'; const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; @@ -11,7 +12,7 @@ const ZRX_META_DATA: ERC20AssetMetaData = { decimals: 18, name: '0x', }; -const ZRX_ASSET: Asset = { +const ZRX_ASSET: ERC20Asset = { assetData: ZRX_ASSET_DATA, metaData: ZRX_META_DATA, }; @@ -45,4 +46,32 @@ describe('assetDataUtil', () => { ).toThrowError(ZeroExInstantError.AssetMetaDataNotAvailable); }); }); + describe('assetBuyerErrorMessage', () => { + it('should return message for InsufficientAssetLiquidity', () => { + const insufficientAssetError = new Error(AssetBuyerError.InsufficientAssetLiquidity); + expect(assetUtils.assetBuyerErrorMessage(ZRX_ASSET, insufficientAssetError)).toEqual( + 'Not enough ZRX available', + ); + }); + it('should return message for InsufficientAssetLiquidity', () => { + const insufficientZrxError = new Error(AssetBuyerError.InsufficientZrxLiquidity); + expect(assetUtils.assetBuyerErrorMessage(ZRX_ASSET, insufficientZrxError)).toEqual( + 'Not enough ZRX available', + ); + }); + it('should message for StandardRelayerApiError', () => { + const standardRelayerError = new Error(AssetBuyerError.StandardRelayerApiError); + expect(assetUtils.assetBuyerErrorMessage(ZRX_ASSET, standardRelayerError)).toEqual( + 'ZRX is currently unavailable', + ); + }); + it('should return error for AssetUnavailable error', () => { + const assetUnavailableError = new Error( + `${AssetBuyerError.AssetUnavailable}: For assetData ${ZRX_ASSET_DATA}`, + ); + expect(assetUtils.assetBuyerErrorMessage(ZRX_ASSET, assetUnavailableError)).toEqual( + 'ZRX is currently unavailable', + ); + }); + }); }); diff --git a/packages/instant/tsconfig.json b/packages/instant/tsconfig.json index 14b0ad8f7..2b3c11c9f 100644 --- a/packages/instant/tsconfig.json +++ b/packages/instant/tsconfig.json @@ -5,8 +5,10 @@ "rootDir": "src", "jsx": "react", "noImplicitAny": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "declaration": false, + "declarationMap": false, + "composite": false }, - "include": ["./src/**/*"], - "exclude": ["./src/index.umd.ts"] + "include": ["./src/**/*"] } diff --git a/packages/instant/webpack.config.js b/packages/instant/webpack.config.js index 41276809c..e74cf36d9 100644 --- a/packages/instant/webpack.config.js +++ b/packages/instant/webpack.config.js @@ -1,32 +1,119 @@ const childProcess = require('child_process'); const ip = require('ip'); const path = require('path'); +const RollbarSourceMapPlugin = require('rollbar-sourcemap-webpack-plugin'); const webpack = require('webpack'); -// The common js bundle (not this one) is built using tsc. -// The umd bundle (this one) has a different entrypoint. - const GIT_SHA = childProcess .execSync('git rev-parse HEAD') .toString() .trim(); -const HEAP_PRODUCTION_ENV_VAR_NAME = 'INSTANT_HEAP_ANALYTICS_ID_PRODUCTION'; -const HEAP_DEVELOPMENT_ENV_VAR_NAME = 'INSTANT_HEAP_ANALYTICS_ID_DEVELOPMENT'; -const getHeapAnalyticsId = modeName => { - if (modeName === 'production') { - return process.env[HEAP_PRODUCTION_ENV_VAR_NAME]; +const DISCHARGE_TARGETS_THAT_REQUIRED_HEAP = ['production', 'staging', 'dogfood']; +const getHeapConfigForDischargeTarget = dischargeTarget => { + return { + heapAnalyticsIdEnvName: + dischargeTarget === 'production' + ? 'INSTANT_HEAP_ANALYTICS_ID_PRODUCTION' + : 'INSTANT_HEAP_ANALYTICS_ID_DEVELOPMENT', + heapAnalyticsIdRequired: DISCHARGE_TARGETS_THAT_REQUIRED_HEAP.includes(dischargeTarget), + }; +}; + +const DISCHARGE_TARGETS_THAT_REQUIRE_ROLLBAR = ['production', 'staging', 'dogfood']; +const getRollbarConfigForDischargeTarget = dischargeTarget => { + if (DISCHARGE_TARGETS_THAT_REQUIRE_ROLLBAR.includes(dischargeTarget)) { + const rollbarSourceMapPublicPath = + dischargeTarget === 'production' + ? 'https://instant.0xproject.com' + : `http://0x-instant-${dischargeTarget}.s3-website-us-east-1.amazonaws.com`; + + return { + rollbarSourceMapPublicPath, + rollbarRequired: true, + }; } - if (modeName === 'development') { - return process.env[HEAP_DEVELOPMENT_ENV_VAR_NAME]; + return { + rollbarRequired: false, + }; +}; + +const ROLLBAR_CLIENT_TOKEN_ENV_VAR_NAME = 'INSTANT_ROLLBAR_CLIENT_TOKEN'; +const ROLLBAR_PUBLISH_TOKEN_ENV_VAR_NAME = 'INSTANT_ROLLBAR_PUBLISH_TOKEN'; +const getRollbarTokens = (dischargeTarget, rollbarRequired) => { + const clientToken = process.env[ROLLBAR_CLIENT_TOKEN_ENV_VAR_NAME]; + const publishToken = process.env[ROLLBAR_PUBLISH_TOKEN_ENV_VAR_NAME]; + + if (rollbarRequired) { + if (!clientToken) { + throw new Error( + `Rollbar client token required for ${dischargeTarget}, please set env var ${ROLLBAR_CLIENT_TOKEN_ENV_VAR_NAME}`, + ); + } + if (!publishToken) { + throw new Error( + `Rollbar publish token required for ${dischargeTarget}, please set env var ${ROLLBAR_PUBLISH_TOKEN_ENV_VAR_NAME}`, + ); + } } - return undefined; + return { clientToken, publishToken }; }; -module.exports = (env, argv) => { +const generateConfig = (dischargeTarget, heapConfigOptions, rollbarConfigOptions, nodeEnv) => { const outputPath = process.env.WEBPACK_OUTPUT_PATH || 'umd'; + + const { heapAnalyticsIdEnvName, heapAnalyticsIdRequired } = heapConfigOptions; + const heapAnalyticsId = process.env[heapAnalyticsIdEnvName]; + if (heapAnalyticsIdRequired && !heapAnalyticsId) { + throw new Error( + `Must define heap analytics id in ENV var ${heapAnalyticsIdEnvName} when building for ${dischargeTarget}`, + ); + } + const heapEnabled = heapAnalyticsId && (nodeEnv !== 'development' || process.env.INSTANT_HEAP_FORCE_DEVELOPMENT); + + const rollbarTokens = getRollbarTokens(dischargeTarget, rollbarConfigOptions.rollbarRequired); + const rollbarEnabled = + rollbarTokens.clientToken && (nodeEnv !== 'development' || process.env.INSTANT_ROLLBAR_FORCE_DEVELOPMENT); + + let rollbarPlugin; + if (rollbarConfigOptions.rollbarRequired) { + if (!rollbarEnabled || !rollbarTokens.publishToken || !rollbarConfigOptions.rollbarSourceMapPublicPath) { + throw new Error(`Rollbar required for ${dischargeTarget} but not configured`); + } + rollbarPlugin = new RollbarSourceMapPlugin({ + accessToken: rollbarTokens.publishToken, + version: GIT_SHA, + publicPath: rollbarConfigOptions.rollbarSourceMapPublicPath, + }); + } + + const envVars = { + GIT_SHA: JSON.stringify(GIT_SHA), + NPM_PACKAGE_VERSION: JSON.stringify(process.env.npm_package_version), + ROLLBAR_ENABLED: rollbarEnabled, + HEAP_ENABLED: heapEnabled + }; + if (dischargeTarget) { + envVars.INSTANT_DISCHARGE_TARGET = JSON.stringify(dischargeTarget); + } + if (heapAnalyticsId) { + envVars.HEAP_ANALYTICS_ID = JSON.stringify(heapAnalyticsId); + } + if (rollbarTokens.clientToken) { + envVars.ROLLBAR_CLIENT_TOKEN = JSON.stringify(rollbarTokens.clientToken); + } + + const plugins = [ + new webpack.DefinePlugin({ + 'process.env': envVars, + }), + ]; + if (rollbarPlugin) { + plugins.push(rollbarPlugin); + } + const config = { entry: { instant: './src/index.umd.ts', @@ -37,15 +124,7 @@ module.exports = (env, argv) => { library: 'zeroExInstant', libraryTarget: 'umd', }, - plugins: [ - new webpack.DefinePlugin({ - 'process.env': { - GIT_SHA: JSON.stringify(GIT_SHA), - HEAP_ANALYTICS_ID: getHeapAnalyticsId(argv.mode), - NPM_PACKAGE_VERSION: JSON.stringify(process.env.npm_package_version), - }, - }), - ], + plugins, devtool: 'source-map', resolve: { extensions: ['.js', '.json', '.ts', '.tsx'], @@ -60,6 +139,15 @@ module.exports = (env, argv) => { test: /\.svg$/, loader: 'svg-react-loader', }, + { + test: /\.js$/, + loader: 'source-map-loader', + exclude: [ + // instead of /\/node_modules\// + path.join(process.cwd(), 'node_modules'), + path.join(process.cwd(), '../..', 'node_modules'), + ], + }, ], }, devServer: { @@ -79,3 +167,10 @@ module.exports = (env, argv) => { }; return config; }; + +module.exports = (env, argv) => { + const dischargeTarget = env ? env.discharge_target : undefined; + const heapConfigOptions = getHeapConfigForDischargeTarget(dischargeTarget); + const rollbarConfigOptions = getRollbarConfigForDischargeTarget(dischargeTarget); + return generateConfig(dischargeTarget, heapConfigOptions, rollbarConfigOptions, argv.mode); +}; |