diff options
Diffstat (limited to 'packages/instant')
102 files changed, 6029 insertions, 46 deletions
diff --git a/packages/instant/.dogfood.discharge.json b/packages/instant/.dogfood.discharge.json new file mode 100644 index 000000000..ca36b3861 --- /dev/null +++ b/packages/instant/.dogfood.discharge.json @@ -0,0 +1,13 @@ +{ + "domain": "0x-instant-dogfood", + "build_command": "WEBPACK_OUTPUT_PATH=public yarn build:umd:prod", + "upload_directory": "public", + "index_key": "index.html", + "error_key": "index.html", + "trailing_slashes": true, + "cache": 3600, + "aws_profile": "default", + "aws_region": "us-east-1", + "cdn": false, + "dns_configured": true +} diff --git a/packages/instant/.gitignore b/packages/instant/.gitignore index e1ce60fa2..a99cea187 100644 --- a/packages/instant/.gitignore +++ b/packages/instant/.gitignore @@ -1,2 +1,3 @@ -public/main.bundle.js -public/main.bundle.js.map
\ No newline at end of file +public/instant.js +public/instant.js.map +umd/*
\ No newline at end of file diff --git a/packages/instant/.npmignore b/packages/instant/.npmignore new file mode 100644 index 000000000..579d65af0 --- /dev/null +++ b/packages/instant/.npmignore @@ -0,0 +1,5 @@ +.* +* +*/ +!lib/src/**/* +!umd/**/*
\ No newline at end of file diff --git a/packages/instant/.staging.discharge.json b/packages/instant/.staging.discharge.json new file mode 100644 index 000000000..c917a650b --- /dev/null +++ b/packages/instant/.staging.discharge.json @@ -0,0 +1,13 @@ +{ + "domain": "0x-instant-staging", + "build_command": "WEBPACK_OUTPUT_PATH=public yarn build:umd:prod", + "upload_directory": "public", + "index_key": "index.html", + "error_key": "index.html", + "trailing_slashes": true, + "cache": 3600, + "aws_profile": "default", + "aws_region": "us-east-1", + "cdn": false, + "dns_configured": true +} diff --git a/packages/instant/README.md b/packages/instant/README.md index 25aca9d3b..b83a10508 100644 --- a/packages/instant/README.md +++ b/packages/instant/README.md @@ -1,9 +1,9 @@ -## @0xproject/instant +## @0x/instant ## Installation ```bash -yarn add @0xproject/instant +yarn add @0x/instant ``` **Import** @@ -11,20 +11,20 @@ yarn add @0xproject/instant **CommonJS module** ```typescript -import { ZeroExInstant } from '@0xproject/instant'; +import { ZeroExInstant } from '@0x/instant'; ``` or ```javascript -var ZeroExInstant = require('@0xproject/instant').ZeroExInstant; +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/@0xproject/typescript-typings/types", "node_modules/@types"], + "typeRoots": ["node_modules/@0x/typescript-typings/types", "node_modules/@types"], } ``` @@ -46,6 +46,26 @@ The package is also available as a UMD module named `zeroExInstant`. </body> ``` +## 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 build and deploy the site 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/ + +To build and deploy to this bucket, 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.** + ## Contributing We welcome improvements and fixes from the wider community! To report bugs within this package, please create an issue in this repository. @@ -71,13 +91,13 @@ yarn install To build this package and all other monorepo packages that it depends on, run the following from the monorepo root directory: ```bash -PKG=@0xproject/instant yarn build +PKG=@0x/instant yarn build ``` Or continuously rebuild on change: ```bash -PKG=@0xproject/instant yarn watch +PKG=@0x/instant yarn watch ``` ### Clean diff --git a/packages/instant/package.json b/packages/instant/package.json index 26c370e4c..bddc88d18 100644 --- a/packages/instant/package.json +++ b/packages/instant/package.json @@ -1,11 +1,11 @@ { - "name": "@0xproject/instant", - "version": "0.0.2", + "name": "@0x/instant", + "version": "1.0.0", "engines": { "node": ">=6.12" }, - "private": true, "description": "0x Instant React Component", + "private": true, "main": "lib/src/index.js", "types": "lib/src/index.d.ts", "scripts": { @@ -16,19 +16,21 @@ "build:ci": "yarn build", "watch_without_deps": "tsc -w", "dev": "webpack-dev-server --mode development", - "lint": "tslint --project .", + "lint": "tslint --format stylish --project .", "test": "jest", "test:coverage": "jest --coverage", "rebuild_and_test": "run-s clean build test", "test:circleci": "yarn test:coverage", "clean": "shx rm -rf lib coverage scripts", + "deploy_dogfood": "discharge deploy -c .dogfood.discharge.json", + "deploy_staging": "discharge deploy -c .staging.discharge.json", "manual:postpublish": "yarn build; node ./scripts/postpublish.js" }, "config": { "postpublish": { "assets": [ - "packages/instant/public/index.js", - "packages/instant/public/index.min.js" + "packages/instant/umd/instant.js", + "packages/instant/umd/instant.js.map" ] } }, @@ -43,28 +45,45 @@ }, "homepage": "https://github.com/0xProject/0x-monorepo/packages/instant/README.md", "dependencies": { - "@0xproject/connect": "^2.0.4", - "@0xproject/types": "^1.1.4", - "@0xproject/typescript-typings": "^2.0.2", - "@0xproject/utils": "^1.0.11", - "@0xproject/web3-wrapper": "^3.0.3", - "ethereum-types": "^1.0.11", + "@0x/assert": "^1.0.17", + "@0x/asset-buyer": "^3.0.0", + "@0x/json-schemas": "^2.1.1", + "@0x/order-utils": "^3.0.2", + "@0x/subproviders": "^2.1.4", + "@0x/types": "^1.2.1", + "@0x/typescript-typings": "^3.0.4", + "@0x/utils": "^2.0.5", + "@0x/web3-wrapper": "^3.1.4", + "bowser": "^1.9.4", + "copy-to-clipboard": "^3.0.8", + "ethereum-types": "^1.1.2", "lodash": "^4.17.10", + "polished": "^2.2.0", "react": "^16.5.2", - "react-dom": "^16.5.2" + "react-dom": "^16.5.2", + "react-redux": "^5.0.7", + "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.5", + "styled-components": "^4.0.2", + "ts-optchain": "^0.1.1" }, "devDependencies": { - "@0xproject/tslint-config": "^1.0.8", + "@0x/tslint-config": "^1.0.10", + "@static/discharge": "https://github.com/0xProject/discharge.git", "@types/enzyme": "^3.1.14", "@types/enzyme-adapter-react-16": "^1.0.3", + "@types/jest": "^23.3.5", "@types/lodash": "^4.14.116", "@types/node": "*", - "@types/react": "16.4.7", + "@types/react": "^16.4.16", "@types/react-dom": "^16.0.8", + "@types/react-redux": "^6.0.9", + "@types/redux": "^3.6.0", + "@types/styled-components": "^4.0.1", "awesome-typescript-loader": "^5.2.1", - "copyfiles": "^1.2.0", "enzyme": "^3.6.0", "enzyme-adapter-react-16": "^1.5.0", + "ip": "^1.1.5", "jest": "^23.6.0", "make-promises-safe": "^1.1.0", "npm-run-all": "^4.1.2", @@ -72,7 +91,7 @@ "shx": "^0.2.2", "ts-jest": "^23.10.3", "tslint": "5.11.0", - "typedoc": "0.12.0", + "typedoc": "0.13.0", "typescript": "3.0.1", "webpack": "^4.20.2", "webpack-cli": "^3.1.1", diff --git a/packages/instant/public/external.css b/packages/instant/public/external.css new file mode 100644 index 000000000..cab11112a --- /dev/null +++ b/packages/instant/public/external.css @@ -0,0 +1,25 @@ +/* + CSS file meant to represent an external (integrators) stylesheet and + help ensure that instant looks consistent across environments. +*/ + +button { + font-size: 50px; + height: 200px; + background-color: red; +} + +input { + padding: 100px; + font-size: 50px; + height: 100px; +} + +div { + padding: 3px; +} + +p { + background-color: green; + margin: 10px; +} diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html index 45968a3c9..253cbb194 100644 --- a/packages/instant/public/index.html +++ b/packages/instant/public/index.html @@ -5,15 +5,132 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>0x Instant Dev Environment</title> - <script type="text/javascript" src="/main.bundle.js" charset="utf-8"></script> + <link rel="stylesheet" href="/external.css"> + <script type="text/javascript" src="/instant.js" charset="utf-8"></script> + <script type="text/javascript" src="https://unpkg.com/jsuri@1.3.1/Uri.js" charset="utf-8"></script> + <script type="text/javascript" src="https://unpkg.com/bignumber.js@4.1.0/bignumber.js" charset="utf-8"></script> + <style> + #zeroExInstantContainer { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + } + + body { + margin: 0; + background-color: rgba(0, 0, 0, 0.2); + } + </style> </head> <body> <div id="zeroExInstantContainer"></div> <script> - zeroExInstant.render({ - + const removeUndefined = (obj) => { + for (let k in obj) if (obj[k] === undefined) delete obj[k]; + return obj; + } + BigNumber.config({ + EXPONENTIAL_AT: 1000, + DECIMAL_PLACES: 78, }); + const providedOrders = [ + // Order selling REP + { + senderAddress: '0x0000000000000000000000000000000000000000', + makerAddress: '0x34a745008a643eebc58920eaa29fb1165b4a288e', + takerAddress: '0x0000000000000000000000000000000000000000', + makerFee: new BigNumber('0'), + takerFee: new BigNumber('0'), + makerAssetAmount: new BigNumber('200000000000000000000'), + takerAssetAmount: new BigNumber('10000000000000000000'), + makerAssetData: '0xf47261b00000000000000000000000008cb3971b8eb709c14616bd556ff6683019e90d9c', + takerAssetData: '0xf47261b0000000000000000000000000d0a1e359811322d97991e03f863a0c30c2cf029c', + expirationTimeSeconds: new BigNumber('1601535600'), + feeRecipientAddress: '0x0000000000000000000000000000000000000000', + salt: new BigNumber('3101985707338942582579795423923841749956600670712030922928319824580764688653'), + signature: '0x1bd4d5686fea801fe33c68c4944356085e7e6cb553eb7073160abd815609f714e85fb47f44b7ffd0a2a1321ac40d72d55163869d0a50fdb5a402132150fe33a08403', + exchangeAddress: '0x35dd2932454449b14cee11a94d3674a936d5d7b2' + }, + // Order selling ZRX + { + senderAddress: '0x0000000000000000000000000000000000000000', + makerAddress: '0x34a745008a643eebc58920eaa29fb1165b4a288e', + takerAddress: '0x0000000000000000000000000000000000000000', + makerFee: new BigNumber('0'), + takerFee: new BigNumber('0'), + makerAssetAmount: new BigNumber('300000000000000000000'), + takerAssetAmount: new BigNumber('31000000000000000000'), + makerAssetData: '0xf47261b00000000000000000000000002002d3812f58e35f0ea1ffbf80a75a38c32175fa', + takerAssetData: '0xf47261b0000000000000000000000000d0a1e359811322d97991e03f863a0c30c2cf029c', + expirationTimeSeconds: new BigNumber('2524636800'), + feeRecipientAddress: '0x0000000000000000000000000000000000000000', + salt: new BigNumber('64592004666704945574675477805199411288137454783320798602050822322450089238268'), + signature: '0x1c13cacddca8d7d8248e91f412377e68f8f1f9891a59a6c1b2eea9f7b33558c30c4fb86a448e08ab7def40a28fb3a3062dcb33bb3c45302447fce5c4288b7c7f5b03', + exchangeAddress: '0x35dd2932454449b14cee11a94d3674a936d5d7b2' + }, + // Order selling GNT + { + senderAddress: '0x0000000000000000000000000000000000000000', + makerAddress: '0x34a745008a643eebc58920eaa29fb1165b4a288e', + takerAddress: '0x0000000000000000000000000000000000000000', + makerFee: new BigNumber('0'), + takerFee: new BigNumber('0'), + makerAssetAmount: new BigNumber('250000000000000000000'), + takerAssetAmount: new BigNumber('10000000000000000000'), + makerAssetData: '0xf47261b000000000000000000000000031fb614e223706f15d0d3c5f4b08bdf0d5c78623', + takerAssetData: '0xf47261b0000000000000000000000000d0a1e359811322d97991e03f863a0c30c2cf029c', + expirationTimeSeconds: new BigNumber('1601535600'), + feeRecipientAddress: '0x0000000000000000000000000000000000000000', + salt: new BigNumber('40204378562212615907903051460421336779451270522691667164301816101569427926606'), + signature: '0x1c788bf4b93769da1e8f195f52f0f59b4a298ac6da30cf6d05a87ed4be5ee974f61352ed1bc6a0844d0962b8c894c9ca08e452431255958a4e98dd93cbe1fbc73803', + exchangeAddress: '0x35dd2932454449b14cee11a94d3674a936d5d7b2' + }, + // Order selling MKR + { + senderAddress: '0x0000000000000000000000000000000000000000', + makerAddress: '0x34a745008a643eebc58920eaa29fb1165b4a288e', + takerAddress: '0x0000000000000000000000000000000000000000', + makerFee: new BigNumber('0'), + takerFee: new BigNumber('0'), + makerAssetAmount: new BigNumber('200000000000000000000'), + takerAssetAmount: new BigNumber('5000000000000000000'), + makerAssetData: '0xf47261b00000000000000000000000007b6b10caa9e8e9552ba72638ea5b47c25afea1f3', + takerAssetData: '0xf47261b0000000000000000000000000d0a1e359811322d97991e03f863a0c30c2cf029c', + expirationTimeSeconds: new BigNumber('1601535600'), + feeRecipientAddress: '0x0000000000000000000000000000000000000000', + salt: new BigNumber('71338269924068280039932133924198049371838034090153601678083172009862985793828'), + signature: '0x1bb3151d57ee1e8fa697767ce83ee4ba77d1ceb8cc1e79c7d77126b3687517704c50c6b3d9cb42c7e7d4478d574b297dfbd1626c5c18a7bc9c2a792c4c07f0797c03', + exchangeAddress: '0x35dd2932454449b14cee11a94d3674a936d5d7b2' + } + ]; + const queryParams = new Uri(window.location.search); + const renderOptionsDefaults = { + orderSource: 'https://api.radarrelay.com/0x/v2/', + onClose: () => { console.log('0x Instant Closed') } + } + const orderSourceOverride = queryParams.getQueryParamValue('orderSource'); + const availableAssetDatasString = queryParams.getQueryParamValue('availableAssetDatas'); + const feeRecipientOverride = queryParams.getQueryParamValue('feeRecipient'); + const feePercentageOverride = +queryParams.getQueryParamValue('feePercentage'); + let affiliateInfoOverride; + if (feeRecipientOverride !== undefined && feePercentageOverride !== undefined) { + affiliateInfoOverride = { + feeRecipient: feeRecipientOverride, + feePercentage: feePercentageOverride + }; + } + const renderOptionsOverrides = { + orderSource: orderSourceOverride === 'provided' ? providedOrders : orderSourceOverride, + networkId: +queryParams.getQueryParamValue('networkId') || undefined, + defaultAssetBuyAmount: +queryParams.getQueryParamValue('defaultAssetBuyAmount') || undefined, + availableAssetDatas: availableAssetDatasString ? JSON.parse(availableAssetDatasString) : undefined, + defaultSelectedAssetData: queryParams.getQueryParamValue('defaultSelectedAssetData'), + affiliateInfo: affiliateInfoOverride, + } + const renderOptions = Object.assign({}, renderOptionsDefaults, removeUndefined(renderOptionsOverrides)); + zeroExInstant.render(renderOptions); </script> </body> diff --git a/packages/instant/src/components/amount_placeholder.tsx b/packages/instant/src/components/amount_placeholder.tsx new file mode 100644 index 000000000..29ce8fafb --- /dev/null +++ b/packages/instant/src/components/amount_placeholder.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Pulse } from './animations/pulse'; + +import { Text } from './ui/text'; + +interface PlainPlaceholder { + color: ColorOption; +} +const PlainPlaceholder: React.StatelessComponent<PlainPlaceholder> = props => ( + <Text fontWeight="bold" fontColor={props.color}> + — + </Text> +); + +export interface AmountPlaceholderProps { + color: ColorOption; + isPulsating: boolean; +} +export const AmountPlaceholder: React.StatelessComponent<AmountPlaceholderProps> = props => { + if (props.isPulsating) { + return ( + <Pulse> + <PlainPlaceholder color={props.color} /> + </Pulse> + ); + } else { + return <PlainPlaceholder color={props.color} />; + } +}; diff --git a/packages/instant/src/components/animations/full_rotation.tsx b/packages/instant/src/components/animations/full_rotation.tsx new file mode 100644 index 000000000..9adb565f9 --- /dev/null +++ b/packages/instant/src/components/animations/full_rotation.tsx @@ -0,0 +1,24 @@ +import { keyframes, styled } from '../../style/theme'; + +interface FullRotationProps { + height: string; + width: string; +} +const rotatingKeyframes = keyframes` +from { + transform: rotate(0deg); +} + +to { + transform: rotate(360deg); +} +`; + +export const FullRotation = + styled.div < + FullRotationProps > + ` + animation: ${rotatingKeyframes} 2s linear infinite; + height: ${props => props.height}; + width: ${props => props.width}; +`; diff --git a/packages/instant/src/components/animations/position_animation.tsx b/packages/instant/src/components/animations/position_animation.tsx new file mode 100644 index 000000000..8b3b294b7 --- /dev/null +++ b/packages/instant/src/components/animations/position_animation.tsx @@ -0,0 +1,110 @@ +import { InterpolationValue } from 'styled-components'; + +import { media, OptionallyScreenSpecific, stylesForMedia } from '../../style/media'; +import { css, keyframes, styled } from '../../style/theme'; + +export interface TransitionInfo { + from: string; + to: string; +} + +const generateTransitionInfoCss = ( + key: keyof TransitionInfo, + top?: TransitionInfo, + bottom?: TransitionInfo, + left?: TransitionInfo, + right?: TransitionInfo, +): string => { + const topStringIfExists = top ? `top: ${top[key]};` : ''; + const bottomStringIfExists = bottom ? `bottom: ${bottom[key]};` : ''; + const leftStringIfExists = left ? `left: ${left[key]};` : ''; + const rightStringIfExists = right ? `right: ${right[key]};` : ''; + return ` + ${topStringIfExists} + ${bottomStringIfExists} + ${leftStringIfExists} + ${rightStringIfExists} + `; +}; + +const slideKeyframeGenerator = ( + position: string, + top?: TransitionInfo, + bottom?: TransitionInfo, + left?: TransitionInfo, + right?: TransitionInfo, +) => keyframes` + from { + position: ${position}; + ${generateTransitionInfoCss('from', top, bottom, left, right)} + } + + to { + position: ${position}; + ${generateTransitionInfoCss('to', top, bottom, left, right)} + } +`; + +export interface PositionAnimationSettings { + top?: TransitionInfo; + bottom?: TransitionInfo; + left?: TransitionInfo; + right?: TransitionInfo; + timingFunction: string; + duration?: string; + position?: string; +} + +const generatePositionAnimationCss = (positionSettings: PositionAnimationSettings) => { + return css` + animation-name: ${slideKeyframeGenerator( + positionSettings.position || 'relative', + positionSettings.top, + positionSettings.bottom, + positionSettings.left, + positionSettings.right, + )}; + animation-duration: ${positionSettings.duration || '0.3s'}; + animation-timing-function: ${positionSettings.timingFunction}; + animation-delay: 0s; + animation-iteration-count: 1; + animation-fill-mode: forwards; + position: ${positionSettings.position || 'relative'}; + width: 100%; + `; +}; + +export interface PositionAnimationProps { + positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>; + zIndex?: OptionallyScreenSpecific<number>; + height?: string; +} + +const defaultAnimation = (positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>) => { + const bestDefault = 'default' in positionSettings ? positionSettings.default : positionSettings; + return generatePositionAnimationCss(bestDefault); +}; +const animationForSize = ( + positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>, + sizeKey: 'sm' | 'md' | 'lg', + mediaFn: (...args: any[]) => InterpolationValue[], +) => { + // checking default makes sure we have a PositionAnimationSettings object + // and then we check to see if we have a setting for the specific `sizeKey` + const animationSettingsForSize = 'default' in positionSettings && positionSettings[sizeKey]; + return animationSettingsForSize && mediaFn`${generatePositionAnimationCss(animationSettingsForSize)}`; +}; + +export const PositionAnimation = + styled.div < + PositionAnimationProps > + ` + && { + ${props => props.zIndex && stylesForMedia<number>('z-index', props.zIndex)} + ${props => defaultAnimation(props.positionSettings)} + ${props => animationForSize(props.positionSettings, 'sm', media.small)} + ${props => animationForSize(props.positionSettings, 'md', media.medium)} + ${props => animationForSize(props.positionSettings, 'lg', media.large)} + ${props => (props.height ? `height: ${props.height};` : '')} + } +`; diff --git a/packages/instant/src/components/animations/pulse.tsx b/packages/instant/src/components/animations/pulse.tsx new file mode 100644 index 000000000..01d6ea070 --- /dev/null +++ b/packages/instant/src/components/animations/pulse.tsx @@ -0,0 +1,15 @@ +import { keyframes, styled } from '../../style/theme'; + +const pulsingKeyframes = keyframes` + 0%, 100% { + opacity: 0.2; + } + 50% { + opacity: 100; + } +`; +export const Pulse = styled.div` + animation-name: ${pulsingKeyframes} + animation-duration: 2s; + animation-iteration-count: infinite; +`; diff --git a/packages/instant/src/components/animations/slide_animation.tsx b/packages/instant/src/components/animations/slide_animation.tsx new file mode 100644 index 000000000..5992bcba7 --- /dev/null +++ b/packages/instant/src/components/animations/slide_animation.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { OptionallyScreenSpecific } from '../../style/media'; +import { SlideAnimationState } from '../../types'; + +import { PositionAnimation, PositionAnimationSettings } from './position_animation'; + +export interface SlideAnimationProps { + animationState: SlideAnimationState; + slideInSettings: OptionallyScreenSpecific<PositionAnimationSettings>; + slideOutSettings: OptionallyScreenSpecific<PositionAnimationSettings>; + zIndex?: OptionallyScreenSpecific<number>; + height?: string; +} + +export const SlideAnimation: React.StatelessComponent<SlideAnimationProps> = props => { + if (props.animationState === 'none') { + return <React.Fragment>{props.children}</React.Fragment>; + } + const positionSettings = props.animationState === 'slidIn' ? props.slideInSettings : props.slideOutSettings; + return ( + <PositionAnimation height={props.height} positionSettings={positionSettings} zIndex={props.zIndex}> + {props.children} + </PositionAnimation> + ); +}; diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx new file mode 100644 index 000000000..8b6121e43 --- /dev/null +++ b/packages/instant/src/components/buy_button.tsx @@ -0,0 +1,100 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import * as React from 'react'; +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 { gasPriceEstimator } from '../util/gas_price_estimator'; +import { util } from '../util/util'; + +import { Button } from './ui/button'; + +export interface BuyButtonProps { + accountAddress?: string; + accountEthBalanceInWei?: BigNumber; + buyQuote?: BuyQuote; + assetBuyer: AssetBuyer; + web3Wrapper: Web3Wrapper; + affiliateInfo?: AffiliateInfo; + onValidationPending: (buyQuote: BuyQuote) => void; + onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; + onSignatureDenied: (buyQuote: BuyQuote) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; + onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; + onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; +} + +export class BuyButton extends React.Component<BuyButtonProps> { + public static defaultProps = { + onClick: util.boundNoop, + onBuySuccess: util.boundNoop, + onBuyFailure: util.boundNoop, + }; + public render(): React.ReactNode { + const { buyQuote, accountAddress } = this.props; + const shouldDisableButton = _.isUndefined(buyQuote) || _.isUndefined(accountAddress); + return ( + <Button + width="100%" + onClick={this._handleClick} + isDisabled={shouldDisableButton} + fontColor={ColorOption.white} + > + Buy + </Button> + ); + } + private readonly _handleClick = async () => { + // The button is disabled when there is no buy quote anyway. + const { buyQuote, assetBuyer, affiliateInfo, accountAddress, accountEthBalanceInWei, web3Wrapper } = this.props; + if (_.isUndefined(buyQuote) || _.isUndefined(accountAddress)) { + return; + } + this.props.onValidationPending(buyQuote); + const ethNeededForBuy = buyQuote.worstCaseQuoteInfo.totalEthAmount; + // 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) { + this.props.onValidationFail(buyQuote, ZeroExInstantError.InsufficientETH); + return; + } + let txHash: string | undefined; + const gasInfo = await gasPriceEstimator.getGasInfoAsync(); + const feeRecipient = oc(affiliateInfo).feeRecipient(); + try { + txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { + feeRecipient, + takerAddress: accountAddress, + gasPrice: gasInfo.gasPriceInWei, + }); + } catch (e) { + if (e instanceof Error) { + if (e.message === AssetBuyerError.SignatureRequestDenied) { + this.props.onSignatureDenied(buyQuote); + return; + } else if (e.message === AssetBuyerError.TransactionValueTooLow) { + this.props.onValidationFail(buyQuote, AssetBuyerError.TransactionValueTooLow); + return; + } + } + throw e; + } + const startTimeUnix = new Date().getTime(); + const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs; + this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); + try { + await web3Wrapper.awaitTransactionSuccessAsync(txHash); + } catch (e) { + if (e instanceof Error && e.message.startsWith(WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX)) { + this.props.onBuyFailure(buyQuote, txHash); + return; + } + throw e; + } + 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 new file mode 100644 index 000000000..6568de91b --- /dev/null +++ b/packages/instant/src/components/buy_order_progress.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { TimedProgressBar } from '../components/timed_progress_bar'; + +import { TimeCounter } from '../components/time_counter'; +import { Container } from '../components/ui/container'; +import { OrderProcessState, OrderState } from '../types'; + +export interface BuyOrderProgressProps { + buyOrderState: OrderState; +} + +export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = props => { + const { buyOrderState } = props; + if ( + buyOrderState.processState === OrderProcessState.Processing || + buyOrderState.processState === OrderProcessState.Success || + buyOrderState.processState === OrderProcessState.Failure + ) { + const progress = buyOrderState.progress; + const hasEnded = buyOrderState.processState !== OrderProcessState.Processing; + const expectedTimeMs = progress.expectedEndTimeUnix - progress.startTimeUnix; + return ( + <Container padding="20px 20px 0px 20px" width="100%"> + <Container marginBottom="5px"> + <TimeCounter estimatedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> + </Container> + <TimedProgressBar expectedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> + </Container> + ); + } + return null; +}; diff --git a/packages/instant/src/components/buy_order_state_buttons.tsx b/packages/instant/src/components/buy_order_state_buttons.tsx new file mode 100644 index 000000000..e563bec73 --- /dev/null +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -0,0 +1,71 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; + +import { BuyButton } from './buy_button'; +import { PlacingOrderButton } from './placing_order_button'; +import { SecondaryButton } from './secondary_button'; + +import { Button } from './ui/button'; +import { Flex } from './ui/flex'; + +export interface BuyOrderStateButtonProps { + accountAddress?: string; + accountEthBalanceInWei?: BigNumber; + buyQuote?: BuyQuote; + buyOrderProcessingState: OrderProcessState; + assetBuyer: AssetBuyer; + web3Wrapper: Web3Wrapper; + affiliateInfo?: AffiliateInfo; + onViewTransaction: () => void; + onValidationPending: (buyQuote: BuyQuote) => void; + onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; + onSignatureDenied: (buyQuote: BuyQuote) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; + onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; + onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; + onRetry: () => void; +} + +export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonProps> = props => { + if (props.buyOrderProcessingState === OrderProcessState.Failure) { + return ( + <Flex justify="space-between"> + <Button width="48%" onClick={props.onRetry} fontColor={ColorOption.white}> + Back + </Button> + <SecondaryButton width="48%" onClick={props.onViewTransaction}> + Details + </SecondaryButton> + </Flex> + ); + } else if ( + props.buyOrderProcessingState === OrderProcessState.Success || + props.buyOrderProcessingState === OrderProcessState.Processing + ) { + return <SecondaryButton onClick={props.onViewTransaction}>View Transaction</SecondaryButton>; + } else if (props.buyOrderProcessingState === OrderProcessState.Validating) { + return <PlacingOrderButton />; + } + + return ( + <BuyButton + accountAddress={props.accountAddress} + accountEthBalanceInWei={props.accountEthBalanceInWei} + buyQuote={props.buyQuote} + assetBuyer={props.assetBuyer} + web3Wrapper={props.web3Wrapper} + affiliateInfo={props.affiliateInfo} + onValidationPending={props.onValidationPending} + onValidationFail={props.onValidationFail} + onSignatureDenied={props.onSignatureDenied} + onBuyProcessing={props.onBuyProcessing} + onBuySuccess={props.onBuySuccess} + onBuyFailure={props.onBuyFailure} + /> + ); +}; diff --git a/packages/instant/src/components/coinbase_wallet_logo.tsx b/packages/instant/src/components/coinbase_wallet_logo.tsx new file mode 100644 index 000000000..845b96d73 --- /dev/null +++ b/packages/instant/src/components/coinbase_wallet_logo.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +export interface CoinbaseWalletLogoProps { + width?: number; +} + +export const CoinbaseWalletLogo: React.StatelessComponent<CoinbaseWalletLogoProps> = ({ width }) => ( + <svg width={width} viewBox="0 0 51 51" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="25.5" cy="25.5" r="25.5" fill="#3263E9" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M25.5 41C34.0604 41 41 34.0604 41 25.5C41 16.9396 34.0604 10 25.5 10C16.9396 10 10 16.9396 10 25.5C10 34.0604 16.9396 41 25.5 41ZM21.5108 20.5107C20.9586 20.5107 20.5108 20.9584 20.5108 21.5107V29.6223C20.5108 30.1746 20.9586 30.6223 21.5108 30.6223H29.6224C30.1747 30.6223 30.6224 30.1746 30.6224 29.6223V21.5107C30.6224 20.9584 30.1747 20.5107 29.6224 20.5107H21.5108Z" + fill="white" + /> + </svg> +); + +CoinbaseWalletLogo.displayName = 'CoinbaseWalletLogo'; + +CoinbaseWalletLogo.defaultProps = { + width: 164, +}; diff --git a/packages/instant/src/components/css_reset.tsx b/packages/instant/src/components/css_reset.tsx new file mode 100644 index 000000000..0bef85389 --- /dev/null +++ b/packages/instant/src/components/css_reset.tsx @@ -0,0 +1,32 @@ +import { INJECTED_DIV_CLASS } from '../constants'; +import { createGlobalStyle } from '../style/theme'; + +export interface CSSResetProps {} + +/* +* Derived from +* https://github.com/jtrost/Complete-CSS-Reset +*/ +export const CSSReset = createGlobalStyle` + .${INJECTED_DIV_CLASS} { + a, abbr, area, article, aside, audio, b, bdo, blockquote, body, button, + canvas, caption, cite, code, col, colgroup, command, datalist, dd, del, + details, dialog, dfn, div, dl, dt, em, embed, fieldset, figure, form, + h1, h2, h3, h4, h5, h6, head, header, hgroup, hr, html, i, iframe, img, + input, ins, keygen, kbd, label, legend, li, map, mark, menu, meter, nav, + noscript, object, ol, optgroup, option, output, p, param, pre, progress, + q, rp, rt, ruby, samp, section, select, small, span, strong, sub, sup, + table, tbody, td, textarea, tfoot, th, thead, time, tr, ul, var, video { + background: transparent; + border: 0; + font-size: 100%; + font: inherit; + margin: 0; + outline: none; + padding: 0; + text-align: left; + text-decoration: none; + vertical-align: baseline; + } + } +`; diff --git a/packages/instant/src/components/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx new file mode 100644 index 000000000..520ac33d5 --- /dev/null +++ b/packages/instant/src/components/erc20_asset_amount_input.tsx @@ -0,0 +1,161 @@ +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption, transparentWhite } from '../style/theme'; +import { ERC20Asset, SimpleHandler } from '../types'; +import { assetUtils } from '../util/asset'; +import { util } from '../util/util'; + +import { ScalingAmountInput } from './scaling_amount_input'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +// Asset amounts only apply to ERC20 assets +export interface ERC20AssetAmountInputProps { + asset?: ERC20Asset; + value?: BigNumber; + onChange: (value?: BigNumber, asset?: ERC20Asset) => void; + onSelectAssetClick?: (asset?: ERC20Asset) => void; + startingFontSizePx: number; + fontColor?: ColorOption; + isDisabled: boolean; + numberOfAssetsAvailable?: number; +} + +export interface ERC20AssetAmountInputState { + currentFontSizePx: number; +} + +export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInputProps, ERC20AssetAmountInputState> { + public static defaultProps = { + onChange: util.boundNoop, + isDisabled: false, + }; + constructor(props: ERC20AssetAmountInputProps) { + super(props); + this.state = { + currentFontSizePx: props.startingFontSizePx, + }; + } + public render(): React.ReactNode { + const { asset } = this.props; + return ( + <Container whiteSpace="nowrap"> + {_.isUndefined(asset) ? this._renderTokenSelectionContent() : this._renderContentForAsset(asset)} + </Container> + ); + } + private readonly _renderContentForAsset = (asset: ERC20Asset): React.ReactNode => { + const { onChange, ...rest } = this.props; + const amountBorderBottom = this.props.isDisabled ? '' : `1px solid ${transparentWhite}`; + const onSymbolClick = this._generateSelectAssetClickHandler(); + return ( + <React.Fragment> + <Container borderBottom={amountBorderBottom} display="inline-block"> + <ScalingAmountInput + {...rest} + textLengthThreshold={this._textLengthThresholdForAsset(asset)} + maxFontSizePx={this.props.startingFontSizePx} + onAmountChange={this._handleChange} + onFontSizeChange={this._handleFontSizeChange} + /> + </Container> + <Container + display="inline-block" + marginLeft="8px" + title={assetUtils.bestNameForAsset(asset, undefined)} + > + <Flex inline={true}> + <Text + fontSize={`${this.state.currentFontSizePx}px`} + fontColor={ColorOption.white} + textTransform="uppercase" + onClick={onSymbolClick} + > + {assetUtils.formattedSymbolForAsset(asset)} + </Text> + {this._renderChevronIcon()} + </Flex> + </Container> + </React.Fragment> + ); + }; + private readonly _renderTokenSelectionContent = (): React.ReactNode => { + const { numberOfAssetsAvailable } = this.props; + let text = 'Select Token'; + if (_.isUndefined(numberOfAssetsAvailable)) { + text = 'Loading...'; + } else if (numberOfAssetsAvailable === 0) { + text = 'Assets Unavailable'; + } + return ( + <Flex> + <Text + fontSize="30px" + fontColor={ColorOption.white} + opacity={0.7} + fontWeight="500" + onClick={this._generateSelectAssetClickHandler()} + > + {text} + </Text> + {this._renderChevronIcon()} + </Flex> + ); + }; + private readonly _renderChevronIcon = (): React.ReactNode => { + if (!this._areMultipleAssetsAvailable()) { + return null; + } + return ( + <Container marginLeft="5px"> + <Icon icon="chevron" width={12} stroke={ColorOption.white} onClick={this._handleSelectAssetClick} /> + </Container> + ); + }; + private readonly _handleChange = (value?: BigNumber): void => { + this.props.onChange(value, this.props.asset); + }; + private readonly _handleFontSizeChange = (fontSizePx: number): void => { + this.setState({ + currentFontSizePx: fontSizePx, + }); + }; + private readonly _generateSelectAssetClickHandler = (): SimpleHandler | undefined => { + // 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)) { + return undefined; + } + return this._handleSelectAssetClick; + }; + private readonly _areMultipleAssetsAvailable = (): boolean => { + const { numberOfAssetsAvailable } = this.props; + return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 1; + }; + private readonly _handleSelectAssetClick = (): void => { + if (this.props.onSelectAssetClick) { + this.props.onSelectAssetClick(); + } + }; + // For assets with symbols of different length, + // start scaling the input at different character lengths + private readonly _textLengthThresholdForAsset = (asset?: ERC20Asset): number => { + if (_.isUndefined(asset)) { + return 3; + } + const symbol = asset.metaData.symbol; + if (symbol.length <= 3) { + return 5; + } + if (symbol.length === 5) { + return 3; + } + return 4; + }; +} diff --git a/packages/instant/src/components/erc20_token_selector.tsx b/packages/instant/src/components/erc20_token_selector.tsx new file mode 100644 index 000000000..e4d8749a9 --- /dev/null +++ b/packages/instant/src/components/erc20_token_selector.tsx @@ -0,0 +1,119 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { ERC20Asset } from '../types'; +import { assetUtils } from '../util/asset'; + +import { SearchInput } from './search_input'; + +import { Circle } from './ui/circle'; +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface ERC20TokenSelectorProps { + tokens: ERC20Asset[]; + onTokenSelect: (token: ERC20Asset) => void; +} + +export interface ERC20TokenSelectorState { + searchQuery?: string; +} + +export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> { + public state: ERC20TokenSelectorState = { + searchQuery: undefined, + }; + public render(): React.ReactNode { + const { tokens, onTokenSelect } = this.props; + return ( + <Container height="100%"> + <Container marginBottom="10px"> + <Text fontColor={ColorOption.darkGrey} fontSize="18px" fontWeight="600" lineHeight="22px"> + Select Token + </Text> + </Container> + <SearchInput + placeholder="Search tokens..." + width="100%" + value={this.state.searchQuery} + onChange={this._handleSearchInputChange} + tabIndex={-1} + /> + <Container overflow="scroll" height="calc(100% - 90px)" marginTop="10px"> + {_.map(tokens, token => { + if (!this._isTokenQueryMatch(token)) { + return null; + } + return <TokenSelectorRow key={token.assetData} token={token} onClick={onTokenSelect} />; + })} + </Container> + </Container> + ); + } + private readonly _handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + const searchQuery = event.target.value; + this.setState({ + searchQuery, + }); + }; + private readonly _isTokenQueryMatch = (token: ERC20Asset): boolean => { + const { searchQuery } = this.state; + if (_.isUndefined(searchQuery)) { + 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); + }; +} + +interface TokenSelectorRowProps { + token: ERC20Asset; + onClick: (token: ERC20Asset) => void; +} + +class TokenSelectorRow extends React.Component<TokenSelectorRowProps> { + public render(): React.ReactNode { + const { token } = this.props; + const displaySymbol = assetUtils.bestNameForAsset(token); + return ( + <Container + padding="12px 0px" + borderBottom="1px solid" + borderColor={ColorOption.feintGrey} + backgroundColor={ColorOption.white} + width="100%" + onClick={this._handleClick} + darkenOnHover={true} + cursor="pointer" + > + <Container marginLeft="5px"> + <Flex justify="flex-start"> + <Container marginRight="10px"> + <Circle diameter={30} rawColor={token.metaData.primaryColor}> + <Flex height="100%"> + <Text fontColor={ColorOption.white} fontSize="8px"> + {displaySymbol} + </Text> + </Flex> + </Circle> + </Container> + <Text fontSize="14px" fontWeight={700} fontColor={ColorOption.black}> + {displaySymbol} + </Text> + <Container margin="0px 5px"> + <Text fontSize="14px"> - </Text> + </Container> + <Text fontSize="14px">{token.metaData.name}</Text> + </Flex> + </Container> + </Container> + ); + } + private readonly _handleClick = (): void => { + this.props.onClick(this.props.token); + }; +} diff --git a/packages/instant/src/components/install_wallet_panel_content.tsx b/packages/instant/src/components/install_wallet_panel_content.tsx new file mode 100644 index 000000000..88c26f59c --- /dev/null +++ b/packages/instant/src/components/install_wallet_panel_content.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import { + META_MASK_CHROME_STORE_URL, + META_MASK_FIREFOX_STORE_URL, + META_MASK_OPERA_STORE_URL, + META_MASK_SITE_URL, +} from '../constants'; +import { ColorOption } from '../style/theme'; +import { Browser } from '../types'; +import { envUtil } from '../util/env'; + +import { MetaMaskLogo } from './meta_mask_logo'; +import { StandardPanelContent, StandardPanelContentProps } from './standard_panel_content'; +import { Button } from './ui/button'; + +export interface InstallWalletPanelContentProps {} + +export class InstallWalletPanelContent extends React.Component<InstallWalletPanelContentProps> { + public render(): React.ReactNode { + const panelProps = this._getStandardPanelContentProps(); + return <StandardPanelContent {...panelProps} />; + } + private readonly _getStandardPanelContentProps = (): StandardPanelContentProps => { + const browser = envUtil.getBrowser(); + let description = 'Please install the MetaMask wallet browser extension.'; + let actionText = 'Learn More'; + let actionUrl = META_MASK_SITE_URL; + switch (browser) { + case Browser.Chrome: + description = 'Please install the MetaMask wallet browser extension from the Chrome Store.'; + actionText = 'Get Chrome Extension'; + actionUrl = META_MASK_CHROME_STORE_URL; + break; + case Browser.Firefox: + description = 'Please install the MetaMask wallet browser extension from the Firefox Store.'; + actionText = 'Get Firefox Extension'; + actionUrl = META_MASK_FIREFOX_STORE_URL; + break; + case Browser.Opera: + description = 'Please install the MetaMask wallet browser extension from the Opera Store.'; + actionText = 'Get Opera Add-on'; + actionUrl = META_MASK_OPERA_STORE_URL; + break; + default: + break; + } + return { + image: <MetaMaskLogo width={85} height={80} />, + title: 'Install MetaMask', + description, + moreInfoSettings: { + href: META_MASK_SITE_URL, + text: 'What is MetaMask?', + }, + action: ( + <Button + href={actionUrl} + width="100%" + fontColor={ColorOption.white} + backgroundColor={ColorOption.darkOrange} + > + {actionText} + </Button> + ), + }; + }; +} diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx new file mode 100644 index 000000000..002695269 --- /dev/null +++ b/packages/instant/src/components/instant_heading.tsx @@ -0,0 +1,132 @@ +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; + +import { SelectedERC20AssetAmountInput } from '../containers/selected_erc20_asset_amount_input'; +import { ColorOption } from '../style/theme'; +import { AsyncProcessState, ERC20Asset, OrderProcessState, OrderState } from '../types'; +import { format } from '../util/format'; + +import { AmountPlaceholder } from './amount_placeholder'; +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Spinner } from './ui/spinner'; +import { Text } from './ui/text'; + +export interface InstantHeadingProps { + selectedAssetUnitAmount?: BigNumber; + totalEthBaseUnitAmount?: BigNumber; + ethUsdPrice?: BigNumber; + quoteRequestState: AsyncProcessState; + buyOrderState: OrderState; + onSelectAssetClick?: (asset?: ERC20Asset) => void; +} + +const PLACEHOLDER_COLOR = ColorOption.white; +const ICON_WIDTH = 34; +const ICON_HEIGHT = 34; +const ICON_COLOR = ColorOption.white; + +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 marginBottom="5px"> + <Text + letterSpacing="1px" + fontColor={ColorOption.white} + opacity={0.7} + fontWeight={500} + textTransform="uppercase" + fontSize="12px" + > + {this._renderTopText()} + </Text> + </Container> + <Flex direction="row" justify="space-between"> + <Flex height="60px"> + <SelectedERC20AssetAmountInput + startingFontSizePx={38} + onSelectAssetClick={this.props.onSelectAssetClick} + /> + </Flex> + <Flex direction="column" justify="space-between"> + {iconOrAmounts} + </Flex> + </Flex> + </Container> + ); + } + + private _renderAmountsSection(): React.ReactNode { + return ( + <Container> + <Container marginBottom="5px">{this._renderPlaceholderOrAmount(this._renderEthAmount)}</Container> + <Container opacity={0.7}>{this._renderPlaceholderOrAmount(this._renderDollarAmount)}</Container> + </Container> + ); + } + + private _renderIcon(): React.ReactNode { + const processState = this.props.buyOrderState.processState; + + if (processState === OrderProcessState.Failure) { + return <Icon icon="failed" width={ICON_WIDTH} height={ICON_HEIGHT} color={ICON_COLOR} />; + } else if (processState === OrderProcessState.Processing) { + return <Spinner widthPx={ICON_HEIGHT} heightPx={ICON_HEIGHT} />; + } else if (processState === OrderProcessState.Success) { + return <Icon icon="success" width={ICON_WIDTH} height={ICON_HEIGHT} color={ICON_COLOR} />; + } + return undefined; + } + + private _renderTopText(): React.ReactNode { + const processState = this.props.buyOrderState.processState; + if (processState === OrderProcessState.Failure) { + return 'Order failed'; + } else if (processState === OrderProcessState.Processing) { + return 'Processing Order...'; + } else if (processState === OrderProcessState.Success) { + return 'Tokens received!'; + } + + return 'I want to buy'; + } + + private _renderPlaceholderOrAmount(amountFunction: () => React.ReactNode): React.ReactNode { + if (this.props.quoteRequestState === AsyncProcessState.Pending) { + return <AmountPlaceholder isPulsating={true} color={PLACEHOLDER_COLOR} />; + } + if (_.isUndefined(this.props.selectedAssetUnitAmount)) { + return <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />; + } + return amountFunction(); + } + + private readonly _renderEthAmount = (): React.ReactNode => { + return ( + <Text fontSize="16px" fontColor={ColorOption.white} fontWeight={500}> + {format.ethBaseUnitAmount( + this.props.totalEthBaseUnitAmount, + 4, + <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, + )} + </Text> + ); + }; + + private readonly _renderDollarAmount = (): React.ReactNode => { + return ( + <Text fontSize="16px" fontColor={ColorOption.white}> + {format.ethBaseUnitAmountInUsd( + this.props.totalEthBaseUnitAmount, + this.props.ethUsdPrice, + 2, + <AmountPlaceholder isPulsating={false} color={ColorOption.white} />, + )} + </Text> + ); + }; +} diff --git a/packages/instant/src/components/meta_mask_logo.tsx b/packages/instant/src/components/meta_mask_logo.tsx new file mode 100644 index 000000000..bfbc67270 --- /dev/null +++ b/packages/instant/src/components/meta_mask_logo.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; + +export interface MetaMaskLogoProps { + width?: number; + height?: number; +} + +export const MetaMaskLogo: React.StatelessComponent<MetaMaskLogoProps> = ({ width, height }) => ( + <svg width={width} height={height} viewBox="0 0 85 80" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M80.578 0L47.7107 24.8648L53.542 10.2702L80.578 0Z" fill="#E2761B" /> + <path d="M4.24075 0L37.1081 25.4053L31.2768 10.2702L4.24075 0Z" fill="#E4761B" /> + <path d="M68.9152 57.8379L59.9032 71.8919L78.9874 77.2973L84.2886 58.3785L68.9152 57.8379Z" fill="#E4761B" /> + <path d="M0.53006 58.3785L5.83124 77.2973L24.9155 71.8919L15.9035 57.8379L0.53006 58.3785Z" fill="#E4761B" /> + <path d="M23.8552 34.5941L18.554 42.7022L37.1082 43.7833L36.5781 23.2428L23.8552 34.5941Z" fill="#E4761B" /> + <path d="M60.9635 34.5941L47.7106 23.2428V43.7833L66.2647 42.7022L60.9635 34.5941Z" fill="#E4761B" /> + <path d="M24.9156 71.8914L36.0481 66.4861L26.5059 58.378L24.9156 71.8914Z" fill="#E4761B" /> + <path d="M48.7709 66.4861L59.9034 71.8914L58.313 58.378L48.7709 66.4861Z" fill="#E4761B" /> + <path d="M59.9034 71.8919L48.7709 66.4865L49.301 73.5135V76.7567L59.9034 71.8919Z" fill="#D7C1B3" /> + <path d="M24.9157 71.892L35.518 76.7568V73.5136L36.0482 66.4866L24.9157 71.892Z" fill="#D7C1B3" /> + <path d="M35.5179 53.5138L25.9758 50.8111L32.8673 47.5678L35.5179 53.5138Z" fill="#233447" /> + <path d="M49.3009 53.5138L51.9515 47.5678L58.843 50.8111L49.3009 53.5138Z" fill="#233447" /> + <path d="M24.9155 71.892L26.5059 57.838L15.9035 58.3785L24.9155 71.892Z" fill="#CD6116" /> + <path d="M58.313 57.838L59.9034 71.892L68.9154 58.3785L58.313 57.838Z" fill="#CD6116" /> + <path + d="M66.2648 42.7025L47.7106 43.7836L49.301 53.5132L51.9516 47.5673L58.8431 50.8106L66.2648 42.7025Z" + fill="#CD6116" + /> + <path + d="M25.9758 50.8106L32.8673 47.5673L35.5179 53.5132L37.1083 43.7836L18.5541 42.7025L25.9758 50.8106Z" + fill="#CD6116" + /> + <path d="M18.5541 42.7024L26.5059 58.378L25.9758 50.8105L18.5541 42.7024Z" fill="#E4751F" /> + <path d="M58.8431 50.8106L58.313 58.3781L66.2647 42.7025L58.8431 50.8106Z" fill="#E4751F" /> + <path d="M37.1083 43.7838L35.518 53.5135L37.6384 65.4053L38.1686 49.7297L37.1083 43.7838Z" fill="#E4751F" /> + <path d="M47.7105 43.7838L46.6503 49.7297L47.1804 65.4053L49.3009 53.5135L47.7105 43.7838Z" fill="#E4751F" /> + <path + d="M49.301 53.5134L47.1805 65.4052L48.7709 66.4863L58.313 58.3782L58.8431 50.8107L49.301 53.5134Z" + fill="#F6851B" + /> + <path + d="M25.9758 50.8107L26.5059 58.3782L36.048 66.4863L37.6384 65.4052L35.5179 53.5134L25.9758 50.8107Z" + fill="#F6851B" + /> + <path + d="M49.3011 76.7568V73.5135L48.771 72.973H36.0482L35.518 73.5135V76.7568L24.9157 71.8919L28.6265 75.1351L36.0482 80H48.771L56.1927 75.1351L59.9035 71.8919L49.3011 76.7568Z" + fill="#C0AD9E" + /> + <path + d="M48.771 66.486L47.1806 65.405H37.6385L36.0482 66.486L35.518 73.513L36.0482 72.9725H48.771L49.3011 73.513L48.771 66.486Z" + fill="#161616" + /> + <path + d="M82.1685 26.4864L84.8191 12.9729L80.5781 0L48.771 24.3242L60.9637 34.5945L78.4576 39.9998L82.1685 35.6755L80.5781 34.0539L83.2287 31.8918L81.1082 30.2702L83.7588 28.108L82.1685 26.4864Z" + fill="#763D16" + /> + <path + d="M0 12.9729L2.65059 26.4864L1.06024 28.108L3.71083 30.2702L1.59036 31.8918L4.24095 34.0539L2.65059 35.6755L6.36142 39.9998L23.8553 34.5945L36.0481 24.3242L4.24095 0L0 12.9729Z" + fill="#763D16" + /> + <path + d="M78.4575 39.9993L60.9636 34.5939L66.2648 42.702L58.313 58.3776H68.9154H84.2888L78.4575 39.9993Z" + fill="#F6851B" + /> + <path + d="M23.8554 34.5939L6.36147 39.9993L0.530167 58.3776H15.9036H26.506L18.5542 42.702L23.8554 34.5939Z" + fill="#F6851B" + /> + <path + d="M47.7106 43.7833L48.7709 24.3239L53.5419 10.2699H31.2769L36.048 24.3239L37.1083 43.7833L37.6384 49.7292V65.4048H47.1805V49.7292L47.7106 43.7833Z" + fill="#F6851B" + /> + </svg> +); + +MetaMaskLogo.displayName = 'MetaMaskLogo'; + +MetaMaskLogo.defaultProps = { + width: 85, + height: 80, +}; diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx new file mode 100644 index 000000000..5fc956e1c --- /dev/null +++ b/packages/instant/src/components/order_details.tsx @@ -0,0 +1,127 @@ +import { BuyQuoteInfo } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { oc } from 'ts-optchain'; + +import { BIG_NUMBER_ZERO } from '../constants'; +import { ColorOption } from '../style/theme'; +import { format } from '../util/format'; + +import { AmountPlaceholder } from './amount_placeholder'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface OrderDetailsProps { + buyQuoteInfo?: BuyQuoteInfo; + selectedAssetUnitAmount?: BigNumber; + ethUsdPrice?: BigNumber; + isLoading: boolean; +} +export class OrderDetails extends React.Component<OrderDetailsProps> { + public render(): React.ReactNode { + const { buyQuoteInfo, ethUsdPrice, selectedAssetUnitAmount } = this.props; + const buyQuoteAccessor = oc(buyQuoteInfo); + const assetEthBaseUnitAmount = buyQuoteAccessor.assetEthAmount(); + const feeEthBaseUnitAmount = buyQuoteAccessor.feeEthAmount(); + const totalEthBaseUnitAmount = buyQuoteAccessor.totalEthAmount(); + const pricePerTokenEth = + !_.isUndefined(assetEthBaseUnitAmount) && + !_.isUndefined(selectedAssetUnitAmount) && + !selectedAssetUnitAmount.eq(BIG_NUMBER_ZERO) + ? assetEthBaseUnitAmount.div(selectedAssetUnitAmount).ceil() + : undefined; + return ( + <Container padding="20px" width="100%" flexGrow={1}> + <Container marginBottom="10px"> + <Text + letterSpacing="1px" + fontColor={ColorOption.primaryColor} + fontWeight={600} + textTransform="uppercase" + fontSize="14px" + > + Order Details + </Text> + </Container> + <EthAmountRow + rowLabel="Token Price" + ethAmount={pricePerTokenEth} + ethUsdPrice={ethUsdPrice} + isLoading={this.props.isLoading} + /> + <EthAmountRow + rowLabel="Fee" + ethAmount={feeEthBaseUnitAmount} + ethUsdPrice={ethUsdPrice} + isLoading={this.props.isLoading} + /> + <EthAmountRow + rowLabel="Total Cost" + ethAmount={totalEthBaseUnitAmount} + ethUsdPrice={ethUsdPrice} + shouldEmphasize={true} + isLoading={this.props.isLoading} + /> + </Container> + ); + } +} + +export interface EthAmountRowProps { + rowLabel: string; + ethAmount?: BigNumber; + isEthAmountInBaseUnits?: boolean; + ethUsdPrice?: BigNumber; + shouldEmphasize?: boolean; + isLoading: boolean; +} + +export class EthAmountRow extends React.Component<EthAmountRowProps> { + public static defaultProps = { + shouldEmphasize: false, + isEthAmountInBaseUnits: true, + }; + public render(): React.ReactNode { + const { rowLabel, ethAmount, isEthAmountInBaseUnits, shouldEmphasize, isLoading } = this.props; + + const fontWeight = shouldEmphasize ? 700 : 400; + const ethFormatter = isEthAmountInBaseUnits ? format.ethBaseUnitAmount : format.ethUnitAmount; + return ( + <Container padding="10px 0px" borderTop="1px dashed" borderColor={ColorOption.feintGrey}> + <Flex justify="space-between"> + <Text fontWeight={fontWeight} fontColor={ColorOption.grey}> + {rowLabel} + </Text> + <Container> + {this._renderUsdSection()} + <Text fontWeight={fontWeight} fontColor={ColorOption.grey}> + {ethFormatter( + ethAmount, + 4, + <Container opacity={0.5}> + <AmountPlaceholder color={ColorOption.lightGrey} isPulsating={isLoading} /> + </Container>, + )} + </Text> + </Container> + </Flex> + </Container> + ); + } + private _renderUsdSection(): React.ReactNode { + const usdFormatter = this.props.isEthAmountInBaseUnits + ? format.ethBaseUnitAmountInUsd + : format.ethUnitAmountInUsd; + const shouldHideUsdPriceSection = _.isUndefined(this.props.ethUsdPrice) || _.isUndefined(this.props.ethAmount); + return shouldHideUsdPriceSection ? null : ( + <Container marginRight="3px" display="inline-block"> + <Text fontColor={ColorOption.lightGrey}> + ({usdFormatter(this.props.ethAmount, this.props.ethUsdPrice)}) + </Text> + </Container> + ); + } +} diff --git a/packages/instant/src/components/payment_method.tsx b/packages/instant/src/components/payment_method.tsx new file mode 100644 index 000000000..ebcd62f35 --- /dev/null +++ b/packages/instant/src/components/payment_method.tsx @@ -0,0 +1,114 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { Account, AccountState, Network } from '../types'; +import { envUtil } from '../util/env'; + +import { CoinbaseWalletLogo } from './coinbase_wallet_logo'; +import { MetaMaskLogo } from './meta_mask_logo'; +import { PaymentMethodDropdown } from './payment_method_dropdown'; +import { Circle } from './ui/circle'; +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; +import { WalletPrompt } from './wallet_prompt'; + +export interface PaymentMethodProps { + account: Account; + network: Network; + walletName: string; + onInstallWalletClick: () => void; + onUnlockWalletClick: () => void; +} + +export class PaymentMethod extends React.Component<PaymentMethodProps> { + public render(): React.ReactNode { + return ( + <Container padding="20px" width="100%"> + <Container marginBottom="12px"> + <Flex justify="space-between"> + <Text + letterSpacing="1px" + fontColor={ColorOption.primaryColor} + fontWeight={600} + textTransform="uppercase" + fontSize="14px" + > + {this._renderTitleText()} + </Text> + {this._renderTitleLabel()} + </Flex> + </Container> + {this._renderMainContent()} + </Container> + ); + } + private readonly _renderTitleText = (): string => { + const { account } = this.props; + switch (account.state) { + case AccountState.Loading: + return 'loading...'; + case AccountState.Locked: + case AccountState.None: + return 'connect your wallet'; + case AccountState.Ready: + return 'payment method'; + } + }; + private readonly _renderTitleLabel = (): React.ReactNode => { + const { account } = this.props; + if (account.state === AccountState.Ready || account.state === AccountState.Locked) { + const circleColor: ColorOption = account.state === AccountState.Ready ? ColorOption.green : ColorOption.red; + return ( + <Flex> + <Circle diameter={8} color={circleColor} /> + <Container marginLeft="3px"> + <Text fontColor={ColorOption.darkGrey} fontSize="12px"> + {this.props.walletName} + </Text> + </Container> + </Flex> + ); + } + return null; + }; + private readonly _renderMainContent = (): React.ReactNode => { + const { account, network } = this.props; + const isMobile = envUtil.isMobileOperatingSystem(); + const logo = isMobile ? <CoinbaseWalletLogo width={22} /> : <MetaMaskLogo width={19} height={18} />; + const primaryColor = isMobile ? ColorOption.darkBlue : ColorOption.darkOrange; + const secondaryColor = isMobile ? ColorOption.lightBlue : ColorOption.lightOrange; + 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" />; + case AccountState.Locked: + return ( + <WalletPrompt + onClick={this.props.onUnlockWalletClick} + image={<Icon width={13} icon="lock" color={ColorOption.black} />} + {...colors} + > + Please Unlock {this.props.walletName} + </WalletPrompt> + ); + case AccountState.None: + return ( + <WalletPrompt onClick={this.props.onInstallWalletClick} image={logo} {...colors}> + {isMobile ? 'Install Coinbase Wallet' : 'Install MetaMask'} + </WalletPrompt> + ); + case AccountState.Ready: + return ( + <PaymentMethodDropdown + accountAddress={account.address} + accountEthBalanceInWei={account.ethBalanceInWei} + network={network} + /> + ); + } + }; +} diff --git a/packages/instant/src/components/payment_method_dropdown.tsx b/packages/instant/src/components/payment_method_dropdown.tsx new file mode 100644 index 000000000..b330dbcd6 --- /dev/null +++ b/packages/instant/src/components/payment_method_dropdown.tsx @@ -0,0 +1,48 @@ +import { BigNumber } from '@0x/utils'; +import copy from 'copy-to-clipboard'; +import * as React from 'react'; + +import { Network } from '../types'; +import { envUtil } from '../util/env'; +import { etherscanUtil } from '../util/etherscan'; +import { format } from '../util/format'; + +import { Dropdown, DropdownItemConfig } from './ui/dropdown'; + +export interface PaymentMethodDropdownProps { + accountAddress: string; + accountEthBalanceInWei?: BigNumber; + network: Network; +} + +export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdownProps> { + public render(): React.ReactNode { + 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()} />; + } + private readonly _getDropdownItemConfigs = (): DropdownItemConfig[] => { + if (envUtil.isMobileOperatingSystem()) { + return []; + } + const viewOnEtherscan = { + text: 'View on Etherscan', + onClick: this._handleEtherscanClick, + }; + const copyAddressToClipboard = { + text: 'Copy address to clipboard', + onClick: this._handleCopyToClipboardClick, + }; + return [viewOnEtherscan, copyAddressToClipboard]; + }; + private readonly _handleEtherscanClick = (): void => { + const { accountAddress, network } = this.props; + const etherscanUrl = etherscanUtil.getEtherScanEthAddressIfExists(accountAddress, network); + window.open(etherscanUrl, '_blank'); + }; + private readonly _handleCopyToClipboardClick = (): void => { + const { accountAddress } = this.props; + copy(accountAddress); + }; +} diff --git a/packages/instant/src/components/placing_order_button.tsx b/packages/instant/src/components/placing_order_button.tsx new file mode 100644 index 000000000..2516b90b1 --- /dev/null +++ b/packages/instant/src/components/placing_order_button.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Button } from './ui/button'; +import { Container } from './ui/container'; +import { Spinner } from './ui/spinner'; + +export const PlacingOrderButton: React.StatelessComponent<{}> = props => ( + <Button isDisabled={true} width="100%" fontColor={ColorOption.white}> + <Container display="inline-block" position="relative" top="3px" marginRight="8px"> + <Spinner widthPx={16} heightPx={16} /> + </Container> + Placing Order… + </Button> +); diff --git a/packages/instant/src/components/scaling_amount_input.tsx b/packages/instant/src/components/scaling_amount_input.tsx new file mode 100644 index 000000000..5dc719293 --- /dev/null +++ b/packages/instant/src/components/scaling_amount_input.tsx @@ -0,0 +1,83 @@ +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; + +import { Maybe } from '../types'; + +import { ColorOption } from '../style/theme'; +import { maybeBigNumberUtil } from '../util/maybe_big_number'; +import { util } from '../util/util'; + +import { ScalingInput } from './scaling_input'; + +export interface ScalingAmountInputProps { + isDisabled: boolean; + maxFontSizePx: number; + textLengthThreshold: number; + fontColor?: ColorOption; + value?: BigNumber; + onAmountChange: (value?: BigNumber) => void; + onFontSizeChange: (fontSizePx: number) => void; +} +interface ScalingAmountInputState { + stringValue: string; +} + +const { stringToMaybeBigNumber, areMaybeBigNumbersEqual } = maybeBigNumberUtil; +export class ScalingAmountInput extends React.Component<ScalingAmountInputProps, ScalingAmountInputState> { + public static defaultProps = { + onAmountChange: util.boundNoop, + onFontSizeChange: util.boundNoop, + isDisabled: false, + }; + public constructor(props: ScalingAmountInputProps) { + super(props); + this.state = { + stringValue: _.isUndefined(props.value) ? '' : props.value.toString(), + }; + } + public componentDidUpdate(): void { + const parsedStateValue = stringToMaybeBigNumber(this.state.stringValue); + const currentValue = this.props.value; + + if (!areMaybeBigNumbersEqual(parsedStateValue, currentValue)) { + // we somehow got into the state in which the value passed in and the string value + // in state have differed, reset state + // we dont expect to ever get into this state, but let's make sure + // we reset if we do since we're dealing with important numbers + this.setState({ + stringValue: _.isUndefined(currentValue) ? '' : currentValue.toString(), + }); + } + } + + public render(): React.ReactNode { + const { textLengthThreshold, fontColor, maxFontSizePx, onFontSizeChange } = this.props; + return ( + <ScalingInput + maxFontSizePx={maxFontSizePx} + textLengthThreshold={textLengthThreshold} + onFontSizeChange={onFontSizeChange} + fontColor={fontColor} + onChange={this._handleChange} + value={this.state.stringValue} + placeholder="0.00" + emptyInputWidthCh={3.5} + isDisabled={this.props.isDisabled} + /> + ); + } + private readonly _handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + const sanitizedValue = event.target.value.replace(/[^0-9.]/g, ''); // only allow numbers and "." + this.setState({ + stringValue: sanitizedValue, + }); + + // Trigger onAmountChange with a valid BigNumber, or undefined if the sanitizedValue is invalid or empty + const bigNumberValue: Maybe<BigNumber> = _.isEmpty(sanitizedValue) + ? undefined + : stringToMaybeBigNumber(sanitizedValue); + + this.props.onAmountChange(bigNumberValue); + }; +} diff --git a/packages/instant/src/components/scaling_input.tsx b/packages/instant/src/components/scaling_input.tsx new file mode 100644 index 000000000..e1599a316 --- /dev/null +++ b/packages/instant/src/components/scaling_input.tsx @@ -0,0 +1,171 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { util } from '../util/util'; + +import { Input } from './ui/input'; + +export enum ScalingInputPhase { + FixedFontSize, + ScalingFontSize, +} + +export interface ScalingSettings { + percentageToReduceFontSizePerCharacter: number; + constantPxToIncreaseWidthPerCharacter: number; +} + +export interface ScalingInputProps { + textLengthThreshold: number; + maxFontSizePx: number; + value: string; + emptyInputWidthCh: number; + onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; + onFontSizeChange: (fontSizePx: number) => void; + fontColor?: ColorOption; + placeholder?: string; + maxLength?: number; + scalingSettings: ScalingSettings; + isDisabled: boolean; +} + +export interface ScalingInputState { + inputWidthPxAtPhaseChange?: number; +} + +export interface ScalingInputSnapshot { + inputWidthPx: number; +} + +// These are magic numbers that were determined experimentally. +const defaultScalingSettings: ScalingSettings = { + percentageToReduceFontSizePerCharacter: 0.125, + constantPxToIncreaseWidthPerCharacter: 4, +}; + +export class ScalingInput extends React.Component<ScalingInputProps, ScalingInputState> { + public static defaultProps = { + onChange: util.boundNoop, + onFontSizeChange: util.boundNoop, + maxLength: 7, + scalingSettings: defaultScalingSettings, + isDisabled: false, + }; + public state: ScalingInputState = { + inputWidthPxAtPhaseChange: undefined, + }; + private readonly _inputRef = React.createRef<HTMLInputElement>(); + public static getPhase(textLengthThreshold: number, value: string): ScalingInputPhase { + if (value.length <= textLengthThreshold) { + return ScalingInputPhase.FixedFontSize; + } + return ScalingInputPhase.ScalingFontSize; + } + public static getPhaseFromProps(props: ScalingInputProps): ScalingInputPhase { + const { value, textLengthThreshold } = props; + return ScalingInput.getPhase(textLengthThreshold, value); + } + public static calculateFontSize( + textLengthThreshold: number, + maxFontSizePx: number, + phase: ScalingInputPhase, + value: string, + percentageToReduceFontSizePerCharacter: number, + ): number { + if (phase !== ScalingInputPhase.ScalingFontSize) { + return maxFontSizePx; + } + const charactersOverMax = value.length - textLengthThreshold; + const scalingFactor = (1 - percentageToReduceFontSizePerCharacter) ** charactersOverMax; + const fontSize = scalingFactor * maxFontSizePx; + return fontSize; + } + public static calculateFontSizeFromProps(props: ScalingInputProps, phase: ScalingInputPhase): number { + const { textLengthThreshold, value, maxFontSizePx, scalingSettings } = props; + return ScalingInput.calculateFontSize( + textLengthThreshold, + maxFontSizePx, + phase, + value, + scalingSettings.percentageToReduceFontSizePerCharacter, + ); + } + public getSnapshotBeforeUpdate(): ScalingInputSnapshot { + return { + inputWidthPx: this._getInputWidthInPx(), + }; + } + public componentDidUpdate( + prevProps: ScalingInputProps, + prevState: ScalingInputState, + snapshot: ScalingInputSnapshot, + ): void { + const prevPhase = ScalingInput.getPhaseFromProps(prevProps); + const curPhase = ScalingInput.getPhaseFromProps(this.props); + // if we went from fixed to scaling, save the width from the transition + if (prevPhase !== ScalingInputPhase.ScalingFontSize && curPhase === ScalingInputPhase.ScalingFontSize) { + this.setState({ + inputWidthPxAtPhaseChange: snapshot.inputWidthPx, + }); + } + // if we went from scaling to fixed, revert back to scaling using `ch` + if (prevPhase === ScalingInputPhase.ScalingFontSize && curPhase !== ScalingInputPhase.ScalingFontSize) { + this.setState({ + inputWidthPxAtPhaseChange: undefined, + }); + } + const prevFontSize = ScalingInput.calculateFontSizeFromProps(prevProps, prevPhase); + const curFontSize = ScalingInput.calculateFontSizeFromProps(this.props, curPhase); + // If font size has changed, notify. + if (prevFontSize !== curFontSize) { + this.props.onFontSizeChange(curFontSize); + } + } + public render(): React.ReactNode { + const { isDisabled, fontColor, onChange, placeholder, value, maxLength } = this.props; + const phase = ScalingInput.getPhaseFromProps(this.props); + return ( + <Input + ref={this._inputRef as any} + fontColor={fontColor} + onChange={onChange} + value={value} + placeholder={placeholder} + fontSize={`${this._calculateFontSize(phase)}px`} + width={this._calculateWidth(phase)} + maxLength={maxLength} + disabled={isDisabled} + /> + ); + } + private readonly _calculateWidth = (phase: ScalingInputPhase): string => { + const { value, textLengthThreshold, scalingSettings } = this.props; + if (_.isEmpty(value)) { + return `${this.props.emptyInputWidthCh}ch`; + } + switch (phase) { + case ScalingInputPhase.FixedFontSize: + return `${value.length}ch`; + case ScalingInputPhase.ScalingFontSize: + const { inputWidthPxAtPhaseChange } = this.state; + if (!_.isUndefined(inputWidthPxAtPhaseChange)) { + const charactersOverMax = value.length - textLengthThreshold; + const scalingAmount = scalingSettings.constantPxToIncreaseWidthPerCharacter * charactersOverMax; + const width = inputWidthPxAtPhaseChange + scalingAmount; + return `${width}px`; + } + return `${textLengthThreshold}ch`; + } + }; + private readonly _calculateFontSize = (phase: ScalingInputPhase): number => { + return ScalingInput.calculateFontSizeFromProps(this.props, phase); + }; + private readonly _getInputWidthInPx = (): number => { + const ref = this._inputRef.current; + if (!ref) { + return 0; + } + return ref.getBoundingClientRect().width; + }; +} diff --git a/packages/instant/src/components/search_input.tsx b/packages/instant/src/components/search_input.tsx new file mode 100644 index 000000000..3a693b9f8 --- /dev/null +++ b/packages/instant/src/components/search_input.tsx @@ -0,0 +1,29 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Input, InputProps } from './ui/input'; + +export interface SearchInputProps extends InputProps { + backgroundColor?: ColorOption; +} + +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} /> + </Flex> + </Container> +); + +SearchInput.displayName = 'SearchInput'; + +SearchInput.defaultProps = { + backgroundColor: ColorOption.lightestGrey, + fontColor: ColorOption.grey, +}; diff --git a/packages/instant/src/components/secondary_button.tsx b/packages/instant/src/components/secondary_button.tsx new file mode 100644 index 000000000..705390e28 --- /dev/null +++ b/packages/instant/src/components/secondary_button.tsx @@ -0,0 +1,26 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Button, ButtonProps } from './ui/button'; + +export interface SecondaryButtonProps extends ButtonProps {} + +export const SecondaryButton: React.StatelessComponent<SecondaryButtonProps> = props => { + const buttonProps = _.omit(props, 'text'); + return ( + <Button + backgroundColor={ColorOption.white} + borderColor={ColorOption.lightGrey} + width={props.width} + onClick={props.onClick} + {...buttonProps} + > + {props.children} + </Button> + ); +}; +SecondaryButton.defaultProps = { + width: '100%', +}; diff --git a/packages/instant/src/components/sliding_error.tsx b/packages/instant/src/components/sliding_error.tsx new file mode 100644 index 000000000..b59e2a905 --- /dev/null +++ b/packages/instant/src/components/sliding_error.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; + +import { ScreenSpecification } from '../style/media'; +import { ColorOption } from '../style/theme'; +import { zIndex } from '../style/z_index'; +import { SlideAnimationState } from '../types'; + +import { PositionAnimationSettings } from './animations/position_animation'; +import { SlideAnimation } from './animations/slide_animation'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface ErrorProps { + icon: string; + message: string; +} + +export const Error: React.StatelessComponent<ErrorProps> = props => ( + <Container + padding="10px" + border={`1px solid ${ColorOption.darkOrange}`} + backgroundColor={ColorOption.lightOrange} + width="100%" + borderRadius="6px" + marginTop="10px" + marginBottom="10px" + > + <Flex justify="flex-start"> + <Container marginRight="5px" display="inline" top="3px" position="relative"> + <Text fontSize="20px">{props.icon}</Text> + </Container> + <Text fontWeight="500" fontColor={ColorOption.darkOrange}> + {props.message} + </Text> + </Flex> + </Container> +); + +export interface SlidingErrorProps extends ErrorProps { + animationState: SlideAnimationState; +} +export const SlidingError: React.StatelessComponent<SlidingErrorProps> = props => { + const slideAmount = '120px'; + + const desktopSlideIn: PositionAnimationSettings = { + timingFunction: 'ease-in', + top: { + from: slideAmount, + to: '0px', + }, + position: 'relative', + }; + const desktopSlideOut: PositionAnimationSettings = { + timingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1)', + top: { + from: '0px', + to: slideAmount, + }, + position: 'relative', + }; + + const mobileSlideIn: PositionAnimationSettings = { + duration: '0.5s', + timingFunction: 'ease-in', + top: { from: '-120px', to: '0px' }, + position: 'fixed', + }; + const moblieSlideOut: PositionAnimationSettings = { + duration: '0.5s', + timingFunction: 'ease-in', + top: { from: '0px', to: '-120px' }, + position: 'fixed', + }; + + const slideUpSettings: ScreenSpecification<PositionAnimationSettings> = { + default: desktopSlideIn, + sm: mobileSlideIn, + }; + const slideOutSettings: ScreenSpecification<PositionAnimationSettings> = { + default: desktopSlideOut, + sm: moblieSlideOut, + }; + + return ( + <SlideAnimation + slideInSettings={slideUpSettings} + slideOutSettings={slideOutSettings} + zIndex={{ sm: zIndex.errorPopup, default: zIndex.errorPopBehind }} + animationState={props.animationState} + > + <Error icon={props.icon} message={props.message} /> + </SlideAnimation> + ); +}; diff --git a/packages/instant/src/components/sliding_panel.tsx b/packages/instant/src/components/sliding_panel.tsx new file mode 100644 index 000000000..7f9037049 --- /dev/null +++ b/packages/instant/src/components/sliding_panel.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { zIndex } from '../style/z_index'; +import { SlideAnimationState } from '../types'; + +import { PositionAnimationSettings } from './animations/position_animation'; +import { SlideAnimation } from './animations/slide_animation'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; + +export interface PanelProps { + onClose?: () => void; +} + +export const Panel: React.StatelessComponent<PanelProps> = ({ children, onClose }) => ( + <Container backgroundColor={ColorOption.white} width="100%" height="100%" zIndex={zIndex.panel} padding="20px"> + <Flex justify="flex-end"> + <Icon padding="5px" width={12} color={ColorOption.lightGrey} icon="closeX" onClick={onClose} /> + </Flex> + <Container position="relative" top="-10px" height="100%"> + {children} + </Container> + </Container> +); + +export interface SlidingPanelProps extends PanelProps { + animationState: SlideAnimationState; +} + +export const SlidingPanel: React.StatelessComponent<SlidingPanelProps> = props => { + if (props.animationState === 'none') { + return null; + } + const { animationState, ...rest } = props; + const slideAmount = '100%'; + const slideUpSettings: PositionAnimationSettings = { + duration: '0.3s', + timingFunction: 'ease-in-out', + top: { + from: slideAmount, + to: '0px', + }, + position: 'absolute', + }; + const slideDownSettings: PositionAnimationSettings = { + duration: '0.3s', + timingFunction: 'ease-out', + top: { + from: '0px', + to: slideAmount, + }, + position: 'absolute', + }; + return ( + <SlideAnimation + slideInSettings={slideUpSettings} + slideOutSettings={slideDownSettings} + animationState={animationState} + height="100%" + > + <Panel {...rest} /> + </SlideAnimation> + ); +}; diff --git a/packages/instant/src/components/standard_panel_content.tsx b/packages/instant/src/components/standard_panel_content.tsx new file mode 100644 index 000000000..582b3318e --- /dev/null +++ b/packages/instant/src/components/standard_panel_content.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface MoreInfoSettings { + text: string; + href: string; +} + +export interface StandardPanelContentProps { + image: React.ReactNode; + title?: string; + description: string; + moreInfoSettings?: MoreInfoSettings; + action: React.ReactNode; +} + +const SPACING_BETWEEN_PX = '20px'; + +export const StandardPanelContent: React.StatelessComponent<StandardPanelContentProps> = ({ + image, + title, + description, + moreInfoSettings, + action, +}) => ( + <Container height="100%"> + <Flex direction="column" height="calc(100% - 58px)"> + <Container marginBottom={SPACING_BETWEEN_PX}>{image}</Container> + {title && ( + <Container marginBottom={SPACING_BETWEEN_PX}> + <Text fontSize="20px" fontWeight={700} fontColor={ColorOption.black}> + {title} + </Text> + </Container> + )} + <Container marginBottom={SPACING_BETWEEN_PX}> + <Text fontSize="14px" fontColor={ColorOption.grey} center={true}> + {description} + </Text> + </Container> + <Container marginBottom={SPACING_BETWEEN_PX}> + {moreInfoSettings && ( + <Text + center={true} + fontSize="13px" + textDecorationLine="underline" + fontColor={ColorOption.lightGrey} + href={moreInfoSettings.href} + > + {moreInfoSettings.text} + </Text> + )} + </Container> + </Flex> + <Container>{action}</Container> + </Container> +); diff --git a/packages/instant/src/components/standard_sliding_panel.tsx b/packages/instant/src/components/standard_sliding_panel.tsx new file mode 100644 index 000000000..f587ff79a --- /dev/null +++ b/packages/instant/src/components/standard_sliding_panel.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { SlideAnimationState, StandardSlidingPanelContent, StandardSlidingPanelSettings } from '../types'; + +import { InstallWalletPanelContent } from './install_wallet_panel_content'; +import { SlidingPanel } from './sliding_panel'; + +export interface StandardSlidingPanelProps extends StandardSlidingPanelSettings { + onClose: () => void; +} + +export class StandardSlidingPanel extends React.Component<StandardSlidingPanelProps> { + public render(): React.ReactNode { + const { animationState, content, onClose } = this.props; + return ( + <SlidingPanel animationState={animationState} onClose={onClose}> + {this._getNodeForContent(content)} + </SlidingPanel> + ); + } + private readonly _getNodeForContent = (content: StandardSlidingPanelContent): React.ReactNode => { + switch (content) { + case StandardSlidingPanelContent.InstallWallet: + return <InstallWalletPanelContent />; + case StandardSlidingPanelContent.None: + return null; + } + }; +} diff --git a/packages/instant/src/components/time_counter.tsx b/packages/instant/src/components/time_counter.tsx new file mode 100644 index 000000000..f9b68163c --- /dev/null +++ b/packages/instant/src/components/time_counter.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { ONE_SECOND_MS } from '../constants'; +import { ColorOption } from '../style/theme'; +import { timeUtil } from '../util/time'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface TimeCounterProps { + estimatedTimeMs: number; + hasEnded: boolean; +} +interface TimeCounterState { + elapsedSeconds: number; +} + +export class TimeCounter extends React.Component<TimeCounterProps, TimeCounterState> { + public state = { + elapsedSeconds: 0, + }; + private _timerId?: number; + + public componentDidMount(): void { + this._setupTimerBasedOnProps(); + } + + public componentWillUnmount(): void { + this._clearTimer(); + } + + public componentDidUpdate(prevProps: TimeCounterProps): void { + if (prevProps.hasEnded !== this.props.hasEnded) { + this._setupTimerBasedOnProps(); + } + } + + public render(): React.ReactNode { + const estimatedTimeSeconds = this.props.estimatedTimeMs / ONE_SECOND_MS; + return ( + <Flex justify="space-between"> + <Container> + <Container marginRight="5px" display="inline"> + <Text fontWeight={600} fontColor={ColorOption.grey}> + Est. Time + </Text> + </Container> + <Text fontColor={ColorOption.grey}> + ({timeUtil.secondsToHumanDescription(estimatedTimeSeconds)}) + </Text> + </Container> + <Text fontColor={ColorOption.grey}> + Time: {timeUtil.secondsToStopwatchTime(this.state.elapsedSeconds)} + </Text> + </Flex> + ); + } + + private _setupTimerBasedOnProps(): void { + this.props.hasEnded ? this._clearTimer() : this._newTimer(); + } + + private _newTimer(): void { + this._clearTimer(); + this._timerId = window.setInterval(() => { + this.setState({ + elapsedSeconds: this.state.elapsedSeconds + 1, + }); + }, ONE_SECOND_MS); + } + + private _clearTimer(): void { + if (this._timerId) { + window.clearInterval(this._timerId); + } + } +} diff --git a/packages/instant/src/components/timed_progress_bar.tsx b/packages/instant/src/components/timed_progress_bar.tsx new file mode 100644 index 000000000..8465b9cd0 --- /dev/null +++ b/packages/instant/src/components/timed_progress_bar.tsx @@ -0,0 +1,100 @@ +import * as _ from 'lodash'; +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 { Container } from './ui/container'; + +export interface TimedProgressBarProps { + expectedTimeMs: number; + hasEnded: boolean; +} + +/** + * Timed Progress Bar + * Goes from 0% -> PROGRESS_STALL_AT_WIDTH over time of expectedTimeMs + * When hasEnded set to true, goes to 100% through animation of PROGRESS_FINISH_ANIMATION_TIME_MS length of time + */ +export class TimedProgressBar extends React.Component<TimedProgressBarProps, {}> { + private readonly _barRef = React.createRef<HTMLDivElement>(); + + public render(): React.ReactNode { + const widthAnimationSettings = this._calculateWidthAnimationSettings(); + return <ProgressBar animationSettings={widthAnimationSettings} ref={this._barRef} />; + } + + private _calculateWidthAnimationSettings(): WidthAnimationSettings { + if (this.props.hasEnded) { + if (!this._barRef.current) { + throw new Error('ended but no reference'); + } + const fromWidth = `${this._barRef.current.offsetWidth}px`; + return { + timeMs: PROGRESS_FINISH_ANIMATION_TIME_MS, + fromWidth, + toWidth: '100%', + }; + } + + return { + timeMs: this.props.expectedTimeMs, + fromWidth: '0px', + toWidth: PROGRESS_STALL_AT_WIDTH, + }; + } +} + +const expandingWidthKeyframes = (fromWidth: string, toWidth: string) => { + return keyframes` + from { + width: ${fromWidth}; + } + to { + width: ${toWidth}; + } + `; +}; + +export interface WidthAnimationSettings { + timeMs: number; + fromWidth: string; + toWidth: string; +} + +interface ProgressProps { + width?: string; + animationSettings?: WidthAnimationSettings; +} + +export const Progress = + styled.div < + ProgressProps > + ` + && { + background-color: ${props => props.theme[ColorOption.primaryColor]}; + border-radius: 6px; + height: 6px; + ${props => (props.width ? `width: ${props.width};` : '')} + ${props => + props.animationSettings + ? css` + animation: ${expandingWidthKeyframes( + props.animationSettings.fromWidth, + props.animationSettings.toWidth, + )} + ${props.animationSettings.timeMs}ms linear 1 forwards; + ` + : ''} + } +`; + +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> + ), +); diff --git a/packages/instant/src/components/ui/button.tsx b/packages/instant/src/components/ui/button.tsx new file mode 100644 index 000000000..e77b1b5d1 --- /dev/null +++ b/packages/instant/src/components/ui/button.tsx @@ -0,0 +1,87 @@ +import { darken, saturate } from 'polished'; +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; +import { util } from '../../util/util'; + +export type ButtonOnClickHandler = (event: React.MouseEvent<HTMLElement>) => void; + +export interface ButtonProps { + backgroundColor?: ColorOption; + borderColor?: ColorOption; + fontColor?: ColorOption; + fontSize?: string; + width?: string; + padding?: string; + type?: string; + isDisabled?: boolean; + href?: string; + onClick?: ButtonOnClickHandler; + className?: string; +} + +const PlainButton: React.StatelessComponent<ButtonProps> = ({ + children, + isDisabled, + onClick, + href, + type, + className, +}) => { + const computedOnClick = isDisabled ? undefined : href ? util.createOpenUrlInNewWindow(href) : onClick; + return ( + <button type={type} className={className} onClick={computedOnClick} disabled={isDisabled}> + {children} + </button> + ); +}; + +const darkenOnHoverAmount = 0.1; +const darkenOnActiveAmount = 0.2; +const saturateOnFocusAmount = 0.2; +export const Button = styled(PlainButton)` + && { + all: initial; + box-sizing: border-box; + font-size: ${props => props.fontSize}; + font-family: 'Inter UI', sans-serif; + font-weight: 500; + color: ${props => props.fontColor && props.theme[props.fontColor]}; + cursor: ${props => (props.isDisabled ? 'default' : 'pointer')}; + transition: background-color, opacity 0.5s ease; + padding: ${props => props.padding}; + border-radius: 3px; + text-align: center; + outline: none; + width: ${props => props.width}; + background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')}; + border: ${props => (props.borderColor ? `1px solid ${props.theme[props.borderColor]}` : 'none')}; + &:hover { + background-color: ${props => + !props.isDisabled + ? darken(darkenOnHoverAmount, props.theme[props.backgroundColor || 'white']) + : ''} !important; + } + &:active { + background-color: ${props => + !props.isDisabled ? darken(darkenOnActiveAmount, props.theme[props.backgroundColor || 'white']) : ''}; + } + &:disabled { + opacity: 0.5; + } + &:focus { + background-color: ${props => + saturate(saturateOnFocusAmount, props.theme[props.backgroundColor || 'white'])}; + } + } +`; + +Button.defaultProps = { + backgroundColor: ColorOption.primaryColor, + width: 'auto', + isDisabled: false, + padding: '.82em 1.2em', + fontSize: '16px', +}; + +Button.displayName = 'Button'; diff --git a/packages/instant/src/components/ui/circle.tsx b/packages/instant/src/components/ui/circle.tsx new file mode 100644 index 000000000..4f9f56f12 --- /dev/null +++ b/packages/instant/src/components/ui/circle.tsx @@ -0,0 +1,27 @@ +import { ColorOption, styled, Theme, withTheme } from '../../style/theme'; + +export interface CircleProps { + diameter: number; + rawColor?: string; + color?: ColorOption; + theme: Theme; +} + +export const Circle = withTheme( + styled.div < + CircleProps > + ` + && { + width: ${props => props.diameter}px; + height: ${props => props.diameter}px; + background-color: ${props => (props.rawColor ? props.rawColor : props.theme[props.color || ColorOption.white])}; + border-radius: 50%; + } +`, +); + +Circle.displayName = 'Circle'; + +Circle.defaultProps = { + color: ColorOption.white, +}; diff --git a/packages/instant/src/components/ui/container.tsx b/packages/instant/src/components/ui/container.tsx new file mode 100644 index 000000000..4dafe1386 --- /dev/null +++ b/packages/instant/src/components/ui/container.tsx @@ -0,0 +1,91 @@ +import { darken } from 'polished'; + +import { MediaChoice, stylesForMedia } from '../../style/media'; +import { ColorOption, styled } from '../../style/theme'; +import { cssRuleIfExists } from '../../style/util'; + +export interface ContainerProps { + display?: MediaChoice; + position?: string; + top?: string; + right?: string; + bottom?: string; + left?: string; + width?: MediaChoice; + height?: MediaChoice; + maxWidth?: string; + margin?: string; + marginTop?: string; + marginRight?: string; + marginBottom?: string; + marginLeft?: string; + padding?: string; + borderRadius?: MediaChoice; + border?: string; + borderColor?: ColorOption; + borderTop?: string; + borderBottom?: string; + className?: string; + backgroundColor?: ColorOption; + hasBoxShadow?: boolean; + zIndex?: number; + whiteSpace?: string; + opacity?: number; + cursor?: string; + overflow?: string; + darkenOnHover?: boolean; + boxShadowOnHover?: boolean; + flexGrow?: string | number; +} + +export const Container = + styled.div < + ContainerProps > + ` + && { + box-sizing: border-box; + ${props => cssRuleIfExists(props, 'flex-grow')} + ${props => cssRuleIfExists(props, 'position')} + ${props => cssRuleIfExists(props, 'top')} + ${props => cssRuleIfExists(props, 'right')} + ${props => cssRuleIfExists(props, 'bottom')} + ${props => cssRuleIfExists(props, 'left')} + ${props => cssRuleIfExists(props, 'max-width')} + ${props => cssRuleIfExists(props, 'margin')} + ${props => cssRuleIfExists(props, 'margin-top')} + ${props => cssRuleIfExists(props, 'margin-right')} + ${props => cssRuleIfExists(props, 'margin-bottom')} + ${props => cssRuleIfExists(props, 'margin-left')} + ${props => cssRuleIfExists(props, 'padding')} + ${props => cssRuleIfExists(props, 'border')} + ${props => cssRuleIfExists(props, 'border-top')} + ${props => cssRuleIfExists(props, 'border-bottom')} + ${props => cssRuleIfExists(props, 'z-index')} + ${props => cssRuleIfExists(props, 'white-space')} + ${props => cssRuleIfExists(props, 'opacity')} + ${props => cssRuleIfExists(props, 'cursor')} + ${props => cssRuleIfExists(props, 'overflow')} + ${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')}; + border-color: ${props => (props.borderColor ? props.theme[props.borderColor] : 'none')}; + &:hover { + ${props => + props.darkenOnHover + ? `background-color: ${ + props.backgroundColor ? darken(0.05, props.theme[props.backgroundColor]) : 'none' + }` + : ''}; + ${props => (props.boxShadowOnHover ? 'box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1)' : '')}; + } + } +`; + +Container.defaultProps = { + display: 'block', +}; + +Container.displayName = 'Container'; diff --git a/packages/instant/src/components/ui/dropdown.tsx b/packages/instant/src/components/ui/dropdown.tsx new file mode 100644 index 000000000..3a23f456d --- /dev/null +++ b/packages/instant/src/components/ui/dropdown.tsx @@ -0,0 +1,134 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption, completelyTransparent } from '../../style/theme'; +import { zIndex } from '../../style/z_index'; + +import { Container } from './container'; +import { Flex } from './flex'; +import { Icon } from './icon'; +import { Overlay } from './overlay'; +import { Text } from './text'; + +export interface DropdownItemConfig { + text: string; + onClick?: () => void; +} + +export interface DropdownProps { + value: string; + label?: string; + items: DropdownItemConfig[]; +} + +export interface DropdownState { + isOpen: boolean; +} + +export class Dropdown extends React.Component<DropdownProps, DropdownState> { + public static defaultProps = { + items: [], + }; + public state: DropdownState = { + isOpen: false, + }; + public render(): React.ReactNode { + const { value, label, items } = this.props; + const { isOpen } = this.state; + const hasItems = !_.isEmpty(items); + const borderRadius = isOpen ? '4px 4px 0px 0px' : '4px'; + return ( + <React.Fragment> + {isOpen && ( + <Overlay + zIndex={zIndex.dropdownItems - 1} + backgroundColor={completelyTransparent} + onClick={this._closeDropdown} + /> + )} + <Container position="relative"> + <Container + cursor={hasItems ? 'pointer' : undefined} + onClick={this._handleDropdownClick} + hasBoxShadow={isOpen} + boxShadowOnHover={true} + borderRadius={borderRadius} + border="1px solid" + borderColor={ColorOption.feintGrey} + padding="0.8em" + > + <Flex justify="space-between"> + <Text fontSize="16px" fontColor={ColorOption.darkGrey}> + {value} + </Text> + <Container> + {label && ( + <Text fontSize="16px" fontColor={ColorOption.lightGrey}> + {label} + </Text> + )} + {hasItems && ( + <Container marginLeft="5px" display="inline-block" position="relative" bottom="2px"> + <Icon padding="3px" icon="chevron" width={12} stroke={ColorOption.grey} /> + </Container> + )} + </Container> + </Flex> + </Container> + {isOpen && ( + <Container + width="100%" + position="absolute" + onClick={this._closeDropdown} + backgroundColor={ColorOption.white} + hasBoxShadow={true} + zIndex={zIndex.dropdownItems} + > + {_.map(items, (item, index) => ( + <DropdownItem key={item.text} {...item} isLast={index === items.length - 1} /> + ))} + </Container> + )} + </Container> + </React.Fragment> + ); + } + private readonly _handleDropdownClick = (): void => { + if (_.isEmpty(this.props.items)) { + return; + } + this.setState({ + isOpen: !this.state.isOpen, + }); + }; + private readonly _closeDropdown = (): void => { + this.setState({ + isOpen: false, + }); + }; +} + +export interface DropdownItemProps extends DropdownItemConfig { + text: string; + onClick?: () => void; + isLast: boolean; +} + +export const DropdownItem: React.StatelessComponent<DropdownItemProps> = ({ text, onClick, isLast }) => ( + <Container + onClick={onClick} + cursor="pointer" + darkenOnHover={true} + backgroundColor={ColorOption.white} + padding="0.8em" + borderTop="0" + border="1px solid" + borderRadius={isLast ? '0px 0px 4px 4px' : undefined} + width="100%" + borderColor={ColorOption.feintGrey} + > + <Text fontSize="14px" fontColor={ColorOption.darkGrey}> + {text} + </Text> + </Container> +); diff --git a/packages/instant/src/components/ui/flex.tsx b/packages/instant/src/components/ui/flex.tsx new file mode 100644 index 000000000..274c46b9e --- /dev/null +++ b/packages/instant/src/components/ui/flex.tsx @@ -0,0 +1,41 @@ +import { MediaChoice, stylesForMedia } from '../../style/media'; +import { ColorOption, styled } from '../../style/theme'; +import { cssRuleIfExists } from '../../style/util'; + +export interface FlexProps { + direction?: 'row' | 'column'; + flexWrap?: 'wrap' | 'nowrap'; + justify?: 'flex-start' | 'center' | 'space-around' | 'space-between' | 'space-evenly' | 'flex-end'; + align?: 'flex-start' | 'center' | 'space-around' | 'space-between' | 'space-evenly' | 'flex-end'; + width?: MediaChoice; + height?: MediaChoice; + backgroundColor?: ColorOption; + inline?: boolean; + flexGrow?: number | string; +} + +export const Flex = + styled.div < + FlexProps > + ` + && { + display: ${props => (props.inline ? 'inline-flex' : 'flex')}; + flex-direction: ${props => props.direction}; + flex-wrap: ${props => props.flexWrap}; + ${props => cssRuleIfExists(props, 'flexGrow')} + justify-content: ${props => props.justify}; + align-items: ${props => props.align}; + background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')}; + ${props => (props.width ? stylesForMedia('width', props.width) : '')} + ${props => (props.height ? stylesForMedia('height', props.height) : '')} + } +`; + +Flex.defaultProps = { + direction: 'row', + flexWrap: 'nowrap', + justify: 'center', + align: 'center', +}; + +Flex.displayName = 'Flex'; diff --git a/packages/instant/src/components/ui/icon.tsx b/packages/instant/src/components/ui/icon.tsx new file mode 100644 index 000000000..811142b5b --- /dev/null +++ b/packages/instant/src/components/ui/icon.tsx @@ -0,0 +1,129 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption, styled, Theme, withTheme } from '../../style/theme'; + +type svgRule = 'evenodd' | 'nonzero' | 'inherit'; +interface IconInfo { + viewBox: string; + path: string; + fillRule?: svgRule; + clipRule?: svgRule; + strokeOpacity?: number; + strokeWidth?: number; + strokeLinecap?: 'butt' | 'round' | 'square' | 'inherit'; + strokeLinejoin?: 'miter' | 'round' | 'bevel' | 'inherit'; +} +interface IconInfoMapping { + closeX: IconInfo; + failed: IconInfo; + success: IconInfo; + chevron: IconInfo; + search: IconInfo; + lock: IconInfo; +} +const ICONS: IconInfoMapping = { + closeX: { + viewBox: '0 0 11 11', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M10.45 10.449C10.7539 10.1453 10.7539 9.65282 10.45 9.34909L6.60068 5.49999L10.45 1.65093C10.7538 1.3472 10.7538 0.854765 10.45 0.551038C10.1462 0.24731 9.65378 0.24731 9.34995 0.551038L5.50058 4.40006L1.65024 0.549939C1.34641 0.246212 0.853973 0.246212 0.550262 0.549939C0.246429 0.853667 0.246429 1.34611 0.550262 1.64983L4.40073 5.49995L0.55014 9.35019C0.246307 9.65392 0.246307 10.1464 0.55014 10.4501C0.853851 10.7538 1.34628 10.7538 1.65012 10.4501L5.5007 6.59987L9.35007 10.449C9.6539 10.7527 10.1463 10.7527 10.45 10.449Z', + }, + failed: { + viewBox: '0 0 34 34', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M6.65771 26.4362C9.21777 29.2406 12.9033 31 17 31C24.7319 31 31 24.7319 31 17C31 14.4468 30.3164 12.0531 29.1226 9.99219L6.65771 26.4362ZM4.88281 24.0173C3.68555 21.9542 3 19.5571 3 17C3 9.26807 9.26807 3 17 3C21.1006 3 24.7891 4.76294 27.3496 7.57214L4.88281 24.0173ZM0 17C0 26.3888 7.61133 34 17 34C26.3887 34 34 26.3888 34 17C34 7.61121 26.3887 0 17 0C7.61133 0 0 7.61121 0 17Z', + }, + success: { + viewBox: '0 0 34 34', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M17 34C26.3887 34 34 26.3888 34 17C34 7.61121 26.3887 0 17 0C7.61133 0 0 7.61121 0 17C0 26.3888 7.61133 34 17 34ZM25.7539 13.0977C26.2969 12.4718 26.2295 11.5244 25.6035 10.9817C24.9775 10.439 24.0303 10.5063 23.4878 11.1323L15.731 20.0771L12.3936 16.7438C11.8071 16.1583 10.8574 16.1589 10.272 16.7451C9.68652 17.3313 9.6875 18.281 10.2734 18.8665L14.75 23.3373L15.8887 24.4746L16.9434 23.2587L25.7539 13.0977Z', + }, + chevron: { + viewBox: '0 0 12 7', + path: 'M11 1L6 6L1 1', + strokeOpacity: 0.5, + strokeWidth: 1.5, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }, + search: { + viewBox: '0 0 14 14', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M8.39404 5.19727C8.39404 6.96289 6.96265 8.39453 5.19702 8.39453C3.4314 8.39453 2 6.96289 2 5.19727C2 3.43164 3.4314 2 5.19702 2C6.96265 2 8.39404 3.43164 8.39404 5.19727ZM8.09668 9.51074C7.26855 10.0684 6.27075 10.3945 5.19702 10.3945C2.3269 10.3945 0 8.06738 0 5.19727C0 2.32715 2.3269 0 5.19702 0C8.06738 0 10.394 2.32715 10.394 5.19727C10.394 6.27051 10.0686 7.26855 9.51074 8.09668L13.6997 12.2861L12.2854 13.7002L8.09668 9.51074Z', + }, + lock: { + viewBox: '0 0 13 16', + path: + 'M6.47619 0C3.79509 0 1.60489 2.21216 1.60489 4.92014V6.33135C0.717479 6.33135 0 7.05602 0 7.95232V14.379C0 15.2753 0.717479 16 1.60489 16H11.3475C12.2349 16 12.9524 15.2753 12.9524 14.379V7.95232C12.9524 7.05602 12.2349 6.33135 11.3475 6.33135V4.92014C11.3475 2.21216 9.1573 0 6.47619 0ZM9.6482 6.33135H3.30418V4.92014C3.30418 3.16567 4.72026 1.71633 6.47619 1.71633C8.23213 1.71633 9.6482 3.16567 9.6482 4.92014V6.33135Z', + }, +}; + +export interface IconProps { + className?: string; + width: number; + height?: number; + color?: ColorOption; + stroke?: ColorOption; + icon: keyof IconInfoMapping; + onClick?: (event: React.MouseEvent<HTMLElement>) => void; + padding?: string; + theme: Theme; +} +const PlainIcon: React.StatelessComponent<IconProps> = props => { + const iconInfo = ICONS[props.icon]; + const colorValue = _.isUndefined(props.color) ? undefined : props.theme[props.color]; + const strokeValue = _.isUndefined(props.stroke) ? undefined : props.theme[props.stroke]; + return ( + <div onClick={props.onClick} className={props.className}> + <svg + width={props.width} + height={props.height} + viewBox={iconInfo.viewBox} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d={iconInfo.path} + fill={colorValue} + fillRule={iconInfo.fillRule || 'nonzero'} + clipRule={iconInfo.clipRule || 'nonzero'} + stroke={strokeValue} + strokeOpacity={iconInfo.strokeOpacity} + strokeWidth={iconInfo.strokeWidth} + strokeLinecap={iconInfo.strokeLinecap} + strokeLinejoin={iconInfo.strokeLinejoin} + /> + </svg> + </div> + ); +}; + +export const Icon = withTheme(styled(PlainIcon)` + && { + display: inline-block; + ${props => (!_.isUndefined(props.onClick) ? 'cursor: pointer' : '')}; + transition: opacity 0.5s ease; + padding: ${props => props.padding}; + opacity: ${props => (!_.isUndefined(props.onClick) ? 0.7 : 1)}; + &:hover { + opacity: 1; + } + &:active { + opacity: 1; + } + } +`); + +Icon.defaultProps = { + padding: '0em 0em', +}; + +Icon.displayName = 'Icon'; diff --git a/packages/instant/src/components/ui/input.tsx b/packages/instant/src/components/ui/input.tsx new file mode 100644 index 000000000..1ea5d8fe1 --- /dev/null +++ b/packages/instant/src/components/ui/input.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; + +export interface InputProps { + tabIndex?: number; + className?: string; + value?: string; + width?: string; + fontSize?: string; + fontColor?: ColorOption; + placeholder?: string; + onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void; +} + +export const Input = + styled.input < + InputProps > + ` + && { + all: initial; + font-size: ${props => props.fontSize}; + width: ${props => props.width}; + padding: 0.1em 0em; + font-family: 'Inter UI'; + color: ${props => props.theme[props.fontColor || 'white']}; + background: transparent; + outline: none; + border: none; + &::placeholder { + color: ${props => props.theme[props.fontColor || 'white']}; + opacity: 0.5; + } + } +`; + +Input.defaultProps = { + width: 'auto', + fontColor: ColorOption.white, + fontSize: '12px', +}; + +Input.displayName = 'Input'; diff --git a/packages/instant/src/components/ui/overlay.tsx b/packages/instant/src/components/ui/overlay.tsx new file mode 100644 index 000000000..f67d6fb2f --- /dev/null +++ b/packages/instant/src/components/ui/overlay.tsx @@ -0,0 +1,39 @@ +import * as _ from 'lodash'; + +import { generateMediaWrapper, ScreenWidths } from '../../style/media'; +import { generateOverlayBlack, styled } from '../../style/theme'; +import { zIndex } from '../../style/z_index'; + +export interface OverlayProps { + zIndex?: number; + backgroundColor?: string; + width?: string; + height?: string; + showMaxWidth?: ScreenWidths; +} + +export const Overlay = + styled.div < + OverlayProps > + ` + && { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: ${props => props.zIndex} + background-color: ${props => props.backgroundColor}; + ${props => props.width && `width: ${props.width};`} + ${props => props.height && `height: ${props.height};`} + display: ${props => (props.showMaxWidth ? 'none' : 'block')}; + ${props => props.showMaxWidth && generateMediaWrapper(props.showMaxWidth)`display: block;`} + } +`; + +Overlay.defaultProps = { + zIndex: zIndex.overlayDefault, + backgroundColor: generateOverlayBlack(0.6), +}; + +Overlay.displayName = 'Overlay'; diff --git a/packages/instant/src/components/ui/spinner.tsx b/packages/instant/src/components/ui/spinner.tsx new file mode 100644 index 000000000..28ebc2598 --- /dev/null +++ b/packages/instant/src/components/ui/spinner.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { FullRotation } from '../animations/full_rotation'; + +export interface SpinnerProps { + widthPx: number; + heightPx: number; +} +export const Spinner: React.StatelessComponent<SpinnerProps> = props => { + return ( + <FullRotation width={`${props.widthPx}px`} height={`${props.heightPx}px`}> + <svg + width={props.widthPx} + height={props.heightPx} + viewBox="0 0 34 34" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="17" cy="17" r="15" stroke="white" strokeOpacity="0.2" strokeWidth="4" /> + <path + d="M17 32C25.2843 32 32 25.2843 32 17C32 8.71573 25.2843 2 17 2" + stroke="white" + strokeWidth="4" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </FullRotation> + ); +}; diff --git a/packages/instant/src/components/ui/text.tsx b/packages/instant/src/components/ui/text.tsx new file mode 100644 index 000000000..fd14cc4d1 --- /dev/null +++ b/packages/instant/src/components/ui/text.tsx @@ -0,0 +1,72 @@ +import { darken } from 'polished'; +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; +import { util } from '../../util/util'; + +export interface TextProps { + fontColor?: ColorOption; + fontFamily?: string; + fontStyle?: string; + fontSize?: string; + opacity?: number; + letterSpacing?: string; + textTransform?: string; + lineHeight?: string; + className?: string; + minHeight?: string; + center?: boolean; + fontWeight?: number | string; + textDecorationLine?: string; + onClick?: (event: React.MouseEvent<HTMLElement>) => void; + noWrap?: boolean; + display?: string; + href?: string; +} + +export const Text: React.StatelessComponent<TextProps> = ({ href, onClick, ...rest }) => { + const computedOnClick = href ? util.createOpenUrlInNewWindow(href) : onClick; + return <StyledText {...rest} onClick={computedOnClick} />; +}; + +const darkenOnHoverAmount = 0.3; +export const StyledText = + styled.div < + TextProps > + ` + && { + font-family: 'Inter UI', sans-serif; + font-style: ${props => props.fontStyle}; + font-weight: ${props => props.fontWeight}; + font-size: ${props => props.fontSize}; + opacity: ${props => props.opacity}; + text-decoration-line: ${props => props.textDecorationLine}; + ${props => (props.lineHeight ? `line-height: ${props.lineHeight}` : '')}; + ${props => (props.center ? 'text-align: center' : '')}; + color: ${props => props.fontColor && props.theme[props.fontColor]}; + ${props => (props.minHeight ? `min-height: ${props.minHeight}` : '')}; + ${props => (props.onClick ? 'cursor: pointer' : '')}; + transition: color 0.5s ease; + ${props => (props.noWrap ? 'white-space: nowrap' : '')}; + ${props => (props.display ? `display: ${props.display}` : '')}; + ${props => (props.letterSpacing ? `letter-spacing: ${props.letterSpacing}` : '')}; + ${props => (props.textTransform ? `text-transform: ${props.textTransform}` : '')}; + &:hover { + ${props => + props.onClick ? `color: ${darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` : ''}; + } + } +`; + +Text.defaultProps = { + fontFamily: 'Inter UI', + fontStyle: 'normal', + fontWeight: 400, + fontColor: ColorOption.black, + fontSize: '15px', + textDecorationLine: 'none', + noWrap: false, + display: 'inline-block', +}; + +Text.displayName = 'Text'; diff --git a/packages/instant/src/components/wallet_prompt.tsx b/packages/instant/src/components/wallet_prompt.tsx new file mode 100644 index 000000000..a0b3ae457 --- /dev/null +++ b/packages/instant/src/components/wallet_prompt.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface WalletPromptProps { + image: React.ReactNode; + onClick?: () => void; + primaryColor: ColorOption; + secondaryColor: ColorOption; +} + +export const WalletPrompt: React.StatelessComponent<WalletPromptProps> = ({ + onClick, + image, + children, + secondaryColor, + primaryColor, +}) => ( + <Container + padding="14.5px" + border={`1px solid ${primaryColor}`} + backgroundColor={secondaryColor} + width="100%" + borderRadius="4px" + onClick={onClick} + cursor={onClick ? 'pointer' : undefined} + boxShadowOnHover={!!onClick} + > + <Flex> + {image} + <Container marginLeft="10px"> + <Text fontSize="16px" fontColor={primaryColor}> + {children} + </Text> + </Container> + </Flex> + </Container> +); + +WalletPrompt.defaultProps = { + primaryColor: ColorOption.darkOrange, + secondaryColor: ColorOption.lightOrange, +}; diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx index 67e1b683d..2267b4dbf 100644 --- a/packages/instant/src/components/zero_ex_instant.tsx +++ b/packages/instant/src/components/zero_ex_instant.tsx @@ -1,5 +1,19 @@ import * as React from 'react'; -export interface ZeroExInstantProps {} +import { ZeroExInstantContainer } from '../components/zero_ex_instant_container'; -export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = () => <div>ZeroExInstant</div>; +import { INJECTED_DIV_CLASS } from '../constants'; + +import { ZeroExInstantProvider, ZeroExInstantProviderProps } from './zero_ex_instant_provider'; + +export type ZeroExInstantProps = ZeroExInstantProviderProps; + +export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = props => { + return ( + <div className={INJECTED_DIV_CLASS}> + <ZeroExInstantProvider {...props}> + <ZeroExInstantContainer /> + </ZeroExInstantProvider> + </div> + ); +}; diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx new file mode 100644 index 000000000..698bfef17 --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; + +import { AvailableERC20TokenSelector } from '../containers/available_erc20_token_selector'; +import { ConnectedBuyOrderProgressOrPaymentMethod } from '../containers/connected_buy_order_progress_or_payment_method'; +import { CurrentStandardSlidingPanel } from '../containers/current_standard_sliding_panel'; +import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order_details'; +import { LatestError } from '../containers/latest_error'; +import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_buy_order_state_buttons'; +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 { 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 ZeroExInstantContainerState { + tokenSelectionPanelAnimationState: SlideAnimationState; +} + +export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantContainerState> { + public state = { + tokenSelectionPanelAnimationState: 'none' as SlideAnimationState, + }; + public render(): React.ReactNode { + return ( + <React.Fragment> + <CSSReset /> + <Container + width={{ default: '350px', sm: '100%' }} + height={{ default: 'auto', sm: '100%' }} + position="relative" + > + <Container position="relative"> + <LatestError /> + </Container> + <Container + zIndex={zIndex.mainContainer} + position="relative" + backgroundColor={ColorOption.white} + borderRadius={{ default: '3px', sm: '0px' }} + hasBoxShadow={true} + overflow="hidden" + height="100%" + > + <Flex direction="column" justify="flex-start" height="100%"> + <SelectedAssetInstantHeading onSelectAssetClick={this._handleSymbolClick} /> + <ConnectedBuyOrderProgressOrPaymentMethod /> + <LatestBuyQuoteOrderDetails /> + <Container padding="20px" width="100%"> + <SelectedAssetBuyOrderStateButtons /> + </Container> + </Flex> + <SlidingPanel + animationState={this.state.tokenSelectionPanelAnimationState} + onClose={this._handlePanelClose} + > + <AvailableERC20TokenSelector onTokenSelect={this._handlePanelClose} /> + </SlidingPanel> + <CurrentStandardSlidingPanel /> + </Container> + </Container> + </React.Fragment> + ); + } + private readonly _handleSymbolClick = (): void => { + this.setState({ + tokenSelectionPanelAnimationState: 'slidIn', + }); + }; + private readonly _handlePanelClose = (): void => { + 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 new file mode 100644 index 000000000..2856ea3e3 --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_overlay.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { ZeroExInstantContainer } from '../components/zero_ex_instant_container'; +import { ColorOption } from '../style/theme'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Overlay } from './ui/overlay'; +import { ZeroExInstantProvider, ZeroExInstantProviderProps } from './zero_ex_instant_provider'; + +export interface ZeroExInstantOverlayProps extends ZeroExInstantProviderProps { + onClose?: () => void; + zIndex?: number; +} + +export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlayProps> = props => { + const { onClose, zIndex, ...rest } = props; + return ( + <ZeroExInstantProvider {...rest}> + <Overlay zIndex={zIndex}> + <Flex height="100vh"> + <Container position="absolute" top="0px" right="0px" display={{ default: 'initial', sm: 'none' }}> + <Icon + height={18} + width={18} + color={ColorOption.white} + icon="closeX" + onClick={onClose} + padding="2em 2em" + /> + </Container> + <Container width={{ default: 'auto', sm: '100%' }} height={{ default: 'auto', sm: '100%' }}> + <ZeroExInstantContainer /> + </Container> + </Flex> + </Overlay> + </ZeroExInstantProvider> + ); +}; diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx new file mode 100644 index 000000000..8be53ee20 --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -0,0 +1,151 @@ +import { ObjectMap } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; + +import { ACCOUNT_UPDATE_INTERVAL_TIME_MS, BUY_QUOTE_UPDATE_INTERVAL_TIME_MS } from '../constants'; +import { SelectedAssetThemeProvider } from '../containers/selected_asset_theme_provider'; +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 { assetUtils } from '../util/asset'; +import { errorFlasher } from '../util/error_flasher'; +import { gasPriceEstimator } from '../util/gas_price_estimator'; +import { Heartbeater } from '../util/heartbeater'; +import { generateAccountHeartbeater, generateBuyQuoteHeartbeater } from '../util/heartbeater_factory'; +import { providerStateFactory } from '../util/provider_state_factory'; + +fonts.include(); + +export type ZeroExInstantProviderProps = ZeroExInstantProviderRequiredProps & + Partial<ZeroExInstantProviderOptionalProps>; + +export interface ZeroExInstantProviderRequiredProps { + orderSource: OrderSource; +} + +export interface ZeroExInstantProviderOptionalProps { + provider: Provider; + availableAssetDatas: string[]; + defaultAssetBuyAmount: number; + defaultSelectedAssetData: string; + additionalAssetMetaDataMap: ObjectMap<AssetMetaData>; + networkId: Network; + affiliateInfo: AffiliateInfo; +} + +export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { + private readonly _store: Store; + private _accountUpdateHeartbeat?: Heartbeater; + private _buyQuoteHeartbeat?: Heartbeater; + + // TODO(fragosti): Write tests for this beast once we inject a provider. + private static _mergeDefaultStateWithProps( + props: ZeroExInstantProviderProps, + defaultState: DefaultState = DEFAULT_STATE, + ): State { + // use the networkId passed in with the props, otherwise default to that of the default state (1, mainnet) + const networkId = props.networkId || defaultState.network; + // construct the ProviderState + const providerState = providerStateFactory.getInitialProviderState( + props.orderSource, + networkId, + props.provider, + ); + // merge the additional additionalAssetMetaDataMap with our default map + const completeAssetMetaDataMap = { + ...props.additionalAssetMetaDataMap, + ...defaultState.assetMetaDataMap, + }; + // construct the final state + const storeStateFromProps: State = { + ...defaultState, + providerState, + network: networkId, + selectedAsset: _.isUndefined(props.defaultSelectedAssetData) + ? undefined + : assetUtils.createAssetFromAssetDataOrThrow( + props.defaultSelectedAssetData, + completeAssetMetaDataMap, + networkId, + ), + selectedAssetUnitAmount: _.isUndefined(props.defaultAssetBuyAmount) + ? undefined + : new BigNumber(props.defaultAssetBuyAmount), + availableAssets: _.isUndefined(props.availableAssetDatas) + ? undefined + : assetUtils.createAssetsFromAssetDatas(props.availableAssetDatas, completeAssetMetaDataMap, networkId), + assetMetaDataMap: completeAssetMetaDataMap, + affiliateInfo: props.affiliateInfo, + }; + return storeStateFromProps; + } + constructor(props: ZeroExInstantProviderProps) { + super(props); + const initialAppState = ZeroExInstantProvider._mergeDefaultStateWithProps(this.props); + this._store = store.create(initialAppState); + } + public componentDidMount(): void { + const state = this._store.getState(); + const dispatch = this._store.dispatch; + // tslint:disable-next-line:no-floating-promises + asyncData.fetchEthPriceAndDispatchToStore(dispatch); + // fetch available assets if none are specified + if (_.isUndefined(state.availableAssets)) { + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAvailableAssetDatasAndDispatchToStore(state, dispatch); + } + if (state.providerState.account.state !== AccountState.None) { + this._accountUpdateHeartbeat = generateAccountHeartbeater({ + store: this._store, + shouldPerformImmediatelyOnStart: true, + }); + this._accountUpdateHeartbeat.start(ACCOUNT_UPDATE_INTERVAL_TIME_MS); + } + + this._buyQuoteHeartbeat = generateBuyQuoteHeartbeater({ + store: this._store, + shouldPerformImmediatelyOnStart: false, + }); + 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 }); + // 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 + gasPriceEstimator.getGasInfoAsync(); + // tslint:disable-next-line:no-floating-promises + this._flashErrorIfWrongNetwork(); + } + public componentWillUnmount(): void { + if (this._accountUpdateHeartbeat) { + this._accountUpdateHeartbeat.stop(); + } + if (this._buyQuoteHeartbeat) { + this._buyQuoteHeartbeat.stop(); + } + } + public render(): React.ReactNode { + return ( + <ReduxProvider store={this._store}> + <SelectedAssetThemeProvider>{this.props.children}</SelectedAssetThemeProvider> + </ReduxProvider> + ); + } + private readonly _flashErrorIfWrongNetwork = async (): Promise<void> => { + const msToShowError = 30000; // 30 seconds + const state = this._store.getState(); + const network = state.network; + const web3Wrapper = state.providerState.web3Wrapper; + const networkOfProvider = await web3Wrapper.getNetworkIdAsync(); + if (network !== networkOfProvider) { + const errorMessage = `Wrong network detected. Try switching to ${Network[network]}.`; + errorFlasher.flashNewErrorMessage(this._store.dispatch, errorMessage, msToShowError); + } + }; +} diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts new file mode 100644 index 000000000..5bd2349b3 --- /dev/null +++ b/packages/instant/src/constants.ts @@ -0,0 +1,50 @@ +import { BigNumber } from '@0x/utils'; + +import { AccountNotReady, AccountState, Network, ProviderType } from './types'; + +export const BIG_NUMBER_ZERO = new BigNumber(0); +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 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 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 ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info'; +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 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/'; +export const META_MASK_FIREFOX_STORE_URL = 'https://addons.mozilla.org/en-US/firefox/addon/ether-metamask/'; +export const META_MASK_CHROME_STORE_URL = + 'https://chrome.google.com/webstore/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn?hl=en'; +export const META_MASK_OPERA_STORE_URL = 'https://addons.opera.com/en/extensions/details/metamask/'; +export const META_MASK_SITE_URL = 'https://metamask.io/'; +export const ETHEREUM_NODE_URL_BY_NETWORK = { + [Network.Mainnet]: 'https://mainnet.infura.io/', + [Network.Kovan]: 'https://kovan.infura.io/', +}; +export const BLOCK_POLLING_INTERVAL_MS = 10000; // 10s +export const NO_ACCOUNT: AccountNotReady = { + state: AccountState.None, +}; +export const LOADING_ACCOUNT: AccountNotReady = { + state: AccountState.Loading, +}; +export const LOCKED_ACCOUNT: AccountNotReady = { + state: AccountState.Locked, +}; +export const PROVIDER_TYPE_TO_NAME: { [key in ProviderType]: string } = { + [ProviderType.Cipher]: 'Cipher', + [ProviderType.MetaMask]: 'MetaMask', + [ProviderType.Mist]: 'Mist', + [ProviderType.CoinbaseWallet]: 'Coinbase Wallet', + [ProviderType.Parity]: 'Parity', +}; diff --git a/packages/instant/src/containers/available_erc20_token_selector.ts b/packages/instant/src/containers/available_erc20_token_selector.ts new file mode 100644 index 000000000..4d4218d22 --- /dev/null +++ b/packages/instant/src/containers/available_erc20_token_selector.ts @@ -0,0 +1,45 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { State } from '../redux/reducer'; +import { ERC20Asset } from '../types'; +import { assetUtils } from '../util/asset'; + +import { ERC20TokenSelector } from '../components/erc20_token_selector'; +import { Action, actions } from '../redux/actions'; + +export interface AvailableERC20TokenSelectorProps { + onTokenSelect?: (token: ERC20Asset) => void; +} + +interface ConnectedState { + tokens: ERC20Asset[]; +} + +interface ConnectedDispatch { + onTokenSelect: (token: ERC20Asset) => void; +} + +const mapStateToProps = (state: State, _ownProps: AvailableERC20TokenSelectorProps): ConnectedState => ({ + tokens: assetUtils.getERC20AssetsFromAssets(state.availableAssets || []), +}); + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + ownProps: AvailableERC20TokenSelectorProps, +): ConnectedDispatch => ({ + onTokenSelect: (token: ERC20Asset) => { + dispatch(actions.updateSelectedAsset(token)); + dispatch(actions.resetAmount()); + if (ownProps.onTokenSelect) { + ownProps.onTokenSelect(token); + } + }, +}); + +export const AvailableERC20TokenSelector: React.ComponentClass<AvailableERC20TokenSelectorProps> = connect( + mapStateToProps, + mapDispatchToProps, +)(ERC20TokenSelector); diff --git a/packages/instant/src/containers/connected_account_payment_method.ts b/packages/instant/src/containers/connected_account_payment_method.ts new file mode 100644 index 000000000..eacbadfca --- /dev/null +++ b/packages/instant/src/containers/connected_account_payment_method.ts @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { PaymentMethod, PaymentMethodProps } from '../components/payment_method'; +import { + COINBASE_WALLET_ANDROID_APP_STORE_URL, + COINBASE_WALLET_IOS_APP_STORE_URL, + COINBASE_WALLET_SITE_URL, +} from '../constants'; +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 { envUtil } from '../util/env'; + +export interface ConnectedAccountPaymentMethodProps {} + +interface ConnectedState { + network: Network; + providerState: ProviderState; +} + +interface ConnectedDispatch { + openInstallWalletPanel: () => void; + unlockWalletAndDispatchToStore: (providerState: ProviderState) => void; +} + +type ConnectedProps = Omit<PaymentMethodProps, keyof ConnectedAccountPaymentMethodProps>; + +type FinalProps = ConnectedProps & ConnectedAccountPaymentMethodProps; + +const mapStateToProps = (state: State, _ownProps: ConnectedAccountPaymentMethodProps): ConnectedState => ({ + network: state.network, + providerState: state.providerState, +}); + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + ownProps: ConnectedAccountPaymentMethodProps, +): ConnectedDispatch => ({ + openInstallWalletPanel: () => dispatch(actions.openStandardSlidingPanel(StandardSlidingPanelContent.InstallWallet)), + unlockWalletAndDispatchToStore: async (providerState: ProviderState) => + asyncData.fetchAccountInfoAndDispatchToStore(providerState, dispatch, true), +}); + +const mergeProps = ( + connectedState: ConnectedState, + connectedDispatch: ConnectedDispatch, + ownProps: ConnectedAccountPaymentMethodProps, +): FinalProps => ({ + ...ownProps, + network: connectedState.network, + account: connectedState.providerState.account, + walletName: connectedState.providerState.name, + onUnlockWalletClick: () => connectedDispatch.unlockWalletAndDispatchToStore(connectedState.providerState), + onInstallWalletClick: () => { + const isMobile = envUtil.isMobileOperatingSystem(); + if (!isMobile) { + 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; + } + window.open(url, '_blank'); + }, +}); + +export const ConnectedAccountPaymentMethod: React.ComponentClass<ConnectedAccountPaymentMethodProps> = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps, +)(PaymentMethod); diff --git a/packages/instant/src/containers/connected_buy_order_progress_or_payment_method.tsx b/packages/instant/src/containers/connected_buy_order_progress_or_payment_method.tsx new file mode 100644 index 000000000..cace18e7e --- /dev/null +++ b/packages/instant/src/containers/connected_buy_order_progress_or_payment_method.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; + +import { State } from '../redux/reducer'; +import { OrderProcessState } from '../types'; + +import { ConnectedAccountPaymentMethod } from './connected_account_payment_method'; +import { SelectedAssetBuyOrderProgress } from './selected_asset_buy_order_progress'; + +interface BuyOrderProgressOrPaymentMethodProps { + orderProcessState: OrderProcessState; +} +export const BuyOrderProgressOrPaymentMethod = (props: BuyOrderProgressOrPaymentMethodProps) => { + const { orderProcessState } = props; + if ( + orderProcessState === OrderProcessState.Processing || + orderProcessState === OrderProcessState.Success || + orderProcessState === OrderProcessState.Failure + ) { + return <SelectedAssetBuyOrderProgress />; + } else { + return <ConnectedAccountPaymentMethod />; + } + return null; +}; + +interface ConnectedState extends BuyOrderProgressOrPaymentMethodProps {} + +export interface ConnectedBuyOrderProgressOrPaymentMethodProps {} +const mapStateToProps = (state: State, _ownProps: ConnectedBuyOrderProgressOrPaymentMethodProps): ConnectedState => ({ + orderProcessState: state.buyOrderState.processState, +}); +export const ConnectedBuyOrderProgressOrPaymentMethod: React.ComponentClass< + ConnectedBuyOrderProgressOrPaymentMethodProps +> = connect(mapStateToProps)(BuyOrderProgressOrPaymentMethod); diff --git a/packages/instant/src/containers/current_standard_sliding_panel.ts b/packages/instant/src/containers/current_standard_sliding_panel.ts new file mode 100644 index 000000000..82ac7fa1b --- /dev/null +++ b/packages/instant/src/containers/current_standard_sliding_panel.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { StandardSlidingPanel } from '../components/standard_sliding_panel'; +import { Action, actions } from '../redux/actions'; +import { State } from '../redux/reducer'; +import { StandardSlidingPanelSettings } from '../types'; + +export interface CurrentStandardSlidingPanelProps {} + +interface ConnectedState extends StandardSlidingPanelSettings {} + +interface ConnectedDispatch { + onClose: () => void; +} + +const mapStateToProps = (state: State, _ownProps: CurrentStandardSlidingPanelProps): ConnectedState => + state.standardSlidingPanelSettings; + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + ownProps: CurrentStandardSlidingPanelProps, +): ConnectedDispatch => ({ + onClose: () => dispatch(actions.closeStandardSlidingPanel()), +}); + +export const CurrentStandardSlidingPanel: React.ComponentClass<CurrentStandardSlidingPanelProps> = connect( + mapStateToProps, + mapDispatchToProps, +)(StandardSlidingPanel); diff --git a/packages/instant/src/containers/latest_buy_quote_order_details.ts b/packages/instant/src/containers/latest_buy_quote_order_details.ts new file mode 100644 index 000000000..5dfe535e7 --- /dev/null +++ b/packages/instant/src/containers/latest_buy_quote_order_details.ts @@ -0,0 +1,32 @@ +import { BuyQuoteInfo } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { oc } from 'ts-optchain'; + +import { State } from '../redux/reducer'; + +import { OrderDetails } from '../components/order_details'; +import { AsyncProcessState } from '../types'; + +export interface LatestBuyQuoteOrderDetailsProps {} + +interface ConnectedState { + buyQuoteInfo?: BuyQuoteInfo; + selectedAssetUnitAmount?: BigNumber; + ethUsdPrice?: BigNumber; + isLoading: boolean; +} + +const mapStateToProps = (state: State, _ownProps: LatestBuyQuoteOrderDetailsProps): ConnectedState => ({ + // use the worst case quote info + buyQuoteInfo: oc(state).latestBuyQuote.worstCaseQuoteInfo(), + selectedAssetUnitAmount: state.selectedAssetUnitAmount, + ethUsdPrice: state.ethUsdPrice, + isLoading: state.quoteRequestState === AsyncProcessState.Pending, +}); + +export const LatestBuyQuoteOrderDetails: React.ComponentClass<LatestBuyQuoteOrderDetailsProps> = connect( + mapStateToProps, +)(OrderDetails); diff --git a/packages/instant/src/containers/latest_error.tsx b/packages/instant/src/containers/latest_error.tsx new file mode 100644 index 000000000..b7cfdb504 --- /dev/null +++ b/packages/instant/src/containers/latest_error.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; + +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { SlidingError } from '../components/sliding_error'; +import { Overlay } from '../components/ui/overlay'; +import { Action } from '../redux/actions'; +import { State } from '../redux/reducer'; +import { ScreenWidths } from '../style/media'; +import { generateOverlayBlack } from '../style/theme'; +import { zIndex } from '../style/z_index'; +import { Asset, DisplayStatus, Omit, SlideAnimationState } from '../types'; +import { errorFlasher } from '../util/error_flasher'; + +export interface LatestErrorComponentProps { + asset?: Asset; + latestErrorMessage?: string; + animationState: SlideAnimationState; + shouldRenderOverlay: boolean; + onOverlayClick: () => void; +} + +export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => { + if (!props.latestErrorMessage) { + return <div />; + } + return ( + <React.Fragment> + <SlidingError animationState={props.animationState} icon="😢" message={props.latestErrorMessage} /> + {props.shouldRenderOverlay && ( + <Overlay + onClick={props.onOverlayClick} + zIndex={zIndex.containerOverlay} + showMaxWidth={ScreenWidths.Sm} + backgroundColor={generateOverlayBlack(0.4)} + /> + )} + </React.Fragment> + ); +}; + +export interface LatestErrorProps {} +interface ConnectedState extends Omit<LatestErrorComponentProps, 'onOverlayClick'> {} +const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({ + asset: state.selectedAsset, + latestErrorMessage: state.latestErrorMessage, + animationState: state.latestErrorDisplayStatus === DisplayStatus.Present ? 'slidIn' : 'slidOut', + shouldRenderOverlay: state.latestErrorDisplayStatus === DisplayStatus.Present, +}); + +type ConnectedDispatch = Pick<LatestErrorComponentProps, 'onOverlayClick'>; +const mapDispatchToProps = (dispatch: Dispatch<Action>, _ownProps: LatestErrorProps): ConnectedDispatch => ({ + onOverlayClick: () => { + errorFlasher.clearError(dispatch); + }, +}); + +export const LatestError = connect(mapStateToProps, mapDispatchToProps)(LatestErrorComponent); diff --git a/packages/instant/src/containers/selected_asset_buy_order_progress.ts b/packages/instant/src/containers/selected_asset_buy_order_progress.ts new file mode 100644 index 000000000..7c8c24676 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_buy_order_progress.ts @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; + +import { BuyOrderProgress } from '../components/buy_order_progress'; +import { State } from '../redux/reducer'; +import { OrderState } from '../types'; + +interface ConnectedState { + buyOrderState: OrderState; +} +const mapStateToProps = (state: State, _ownProps: {}): ConnectedState => ({ + buyOrderState: state.buyOrderState, +}); +export const SelectedAssetBuyOrderProgress = connect(mapStateToProps)(BuyOrderProgress); 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 new file mode 100644 index 000000000..610335243 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts @@ -0,0 +1,104 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +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 { errorFlasher } from '../util/error_flasher'; +import { etherscanUtil } from '../util/etherscan'; + +interface ConnectedState { + accountAddress?: string; + accountEthBalanceInWei?: BigNumber; + buyQuote?: BuyQuote; + buyOrderProcessingState: OrderProcessState; + assetBuyer: AssetBuyer; + web3Wrapper: Web3Wrapper; + affiliateInfo?: AffiliateInfo; + onViewTransaction: () => void; +} + +interface ConnectedDispatch { + onValidationPending: (buyQuote: BuyQuote) => void; + onSignatureDenied: (buyQuote: BuyQuote) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; + onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; + onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; + onRetry: () => void; + onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; +} +export interface SelectedAssetBuyOrderStateButtons {} +const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButtons): ConnectedState => { + const assetBuyer = state.providerState.assetBuyer; + const web3Wrapper = state.providerState.web3Wrapper; + const account = state.providerState.account; + const accountAddress = account.state === AccountState.Ready ? account.address : undefined; + const accountEthBalanceInWei = account.state === AccountState.Ready ? account.ethBalanceInWei : undefined; + return { + accountAddress, + accountEthBalanceInWei, + buyOrderProcessingState: state.buyOrderState.processState, + assetBuyer, + web3Wrapper, + buyQuote: state.latestBuyQuote, + affiliateInfo: state.affiliateInfo, + onViewTransaction: () => { + if ( + state.buyOrderState.processState === OrderProcessState.Processing || + state.buyOrderState.processState === OrderProcessState.Success || + state.buyOrderState.processState === OrderProcessState.Failure + ) { + const etherscanUrl = etherscanUtil.getEtherScanTxnAddressIfExists( + state.buyOrderState.txHash, + assetBuyer.networkId, + ); + if (etherscanUrl) { + window.open(etherscanUrl, '_blank'); + return; + } + } + }, + }; +}; + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + ownProps: SelectedAssetBuyOrderStateButtons, +): ConnectedDispatch => ({ + onValidationPending: (buyQuote: BuyQuote) => { + dispatch(actions.setBuyOrderStateValidating()); + }, + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => { + dispatch(actions.setBuyOrderStateProcessing(txHash, startTimeUnix, expectedEndTimeUnix)); + }, + onBuySuccess: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateSuccess(txHash)), + onBuyFailure: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateFailure(txHash)), + onSignatureDenied: () => { + dispatch(actions.resetAmount()); + const errorMessage = 'You denied this transaction'; + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + }, + onValidationFail: (buyQuote, error) => { + dispatch(actions.setBuyOrderStateNone()); + if (error === ZeroExInstantError.InsufficientETH) { + const errorMessage = "You don't have enough ETH"; + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + } else { + errorFlasher.flashNewErrorMessage(dispatch); + } + }, + onRetry: () => { + dispatch(actions.resetAmount()); + }, +}); + +export const SelectedAssetBuyOrderStateButtons: React.ComponentClass<SelectedAssetBuyOrderStateButtons> = connect( + mapStateToProps, + mapDispatchToProps, +)(BuyOrderStateButtons); diff --git a/packages/instant/src/containers/selected_asset_instant_heading.ts b/packages/instant/src/containers/selected_asset_instant_heading.ts new file mode 100644 index 000000000..8dc127e1d --- /dev/null +++ b/packages/instant/src/containers/selected_asset_instant_heading.ts @@ -0,0 +1,34 @@ +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { oc } from 'ts-optchain'; + +import { State } from '../redux/reducer'; +import { AsyncProcessState, ERC20Asset, OrderState } from '../types'; + +import { InstantHeading } from '../components/instant_heading'; + +export interface InstantHeadingProps { + onSelectAssetClick?: (asset?: ERC20Asset) => void; +} + +interface ConnectedState { + selectedAssetUnitAmount?: BigNumber; + totalEthBaseUnitAmount?: BigNumber; + ethUsdPrice?: BigNumber; + quoteRequestState: AsyncProcessState; + buyOrderState: OrderState; +} + +const mapStateToProps = (state: State, _ownProps: InstantHeadingProps): ConnectedState => ({ + selectedAssetUnitAmount: state.selectedAssetUnitAmount, + totalEthBaseUnitAmount: oc(state).latestBuyQuote.worstCaseQuoteInfo.totalEthAmount(), + ethUsdPrice: state.ethUsdPrice, + quoteRequestState: state.quoteRequestState, + buyOrderState: state.buyOrderState, +}); + +export const SelectedAssetInstantHeading: React.ComponentClass<InstantHeadingProps> = connect(mapStateToProps)( + InstantHeading, +); diff --git a/packages/instant/src/containers/selected_asset_theme_provider.ts b/packages/instant/src/containers/selected_asset_theme_provider.ts new file mode 100644 index 000000000..6e6b83d73 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_theme_provider.ts @@ -0,0 +1,32 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; + +import { State } from '../redux/reducer'; +import { Theme, theme as defaultTheme, ThemeProvider } from '../style/theme'; +import { Asset } from '../types'; + +export interface SelectedAssetThemeProviderProps {} + +interface ConnectedState { + theme: Theme; +} + +const getTheme = (asset?: Asset): Theme => { + if (!_.isUndefined(asset) && !_.isUndefined(asset.metaData.primaryColor)) { + return { + ...defaultTheme, + primaryColor: asset.metaData.primaryColor, + }; + } + return defaultTheme; +}; + +const mapStateToProps = (state: State, _ownProps: SelectedAssetThemeProviderProps): ConnectedState => { + const theme = getTheme(state.selectedAsset); + return { theme }; +}; + +export const SelectedAssetThemeProvider: React.ComponentClass<SelectedAssetThemeProviderProps> = connect( + mapStateToProps, +)(ThemeProvider); diff --git a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts new file mode 100644 index 000000000..2c2661e1a --- /dev/null +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -0,0 +1,114 @@ +import { AssetBuyer } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { ERC20AssetAmountInput, ERC20AssetAmountInputProps } from '../components/erc20_asset_amount_input'; +import { Action, actions } from '../redux/actions'; +import { State } from '../redux/reducer'; +import { ColorOption } from '../style/theme'; +import { AffiliateInfo, ERC20Asset, Omit, OrderProcessState } from '../types'; +import { buyQuoteUpdater } from '../util/buy_quote_updater'; + +export interface SelectedERC20AssetAmountInputProps { + fontColor?: ColorOption; + startingFontSizePx: number; + onSelectAssetClick?: (asset?: ERC20Asset) => void; +} + +interface ConnectedState { + assetBuyer: AssetBuyer; + value?: BigNumber; + asset?: ERC20Asset; + isDisabled: boolean; + numberOfAssetsAvailable?: number; + affiliateInfo?: AffiliateInfo; +} + +interface ConnectedDispatch { + updateBuyQuote: ( + assetBuyer: AssetBuyer, + value?: BigNumber, + asset?: ERC20Asset, + affiliateInfo?: AffiliateInfo, + ) => void; +} + +type ConnectedProps = Omit<ERC20AssetAmountInputProps, keyof SelectedERC20AssetAmountInputProps>; + +type FinalProps = ConnectedProps & SelectedERC20AssetAmountInputProps; + +const mapStateToProps = (state: State, _ownProps: SelectedERC20AssetAmountInputProps): ConnectedState => { + const processState = state.buyOrderState.processState; + const isEnabled = processState === OrderProcessState.None || processState === OrderProcessState.Failure; + const isDisabled = !isEnabled; + const selectedAsset = + !_.isUndefined(state.selectedAsset) && state.selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20 + ? (state.selectedAsset as ERC20Asset) + : undefined; + const numberOfAssetsAvailable = _.isUndefined(state.availableAssets) ? undefined : state.availableAssets.length; + const assetBuyer = state.providerState.assetBuyer; + return { + assetBuyer, + value: state.selectedAssetUnitAmount, + asset: selectedAsset, + isDisabled, + numberOfAssetsAvailable, + affiliateInfo: state.affiliateInfo, + }; +}; + +const debouncedUpdateBuyQuoteAsync = _.debounce(buyQuoteUpdater.updateBuyQuoteAsync.bind(buyQuoteUpdater), 200, { + trailing: true, +}) as typeof buyQuoteUpdater.updateBuyQuoteAsync; + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + _ownProps: SelectedERC20AssetAmountInputProps, +): ConnectedDispatch => ({ + updateBuyQuote: (assetBuyer, value, asset, affiliateInfo) => { + // Update the input + dispatch(actions.updateSelectedAssetAmount(value)); + // invalidate the last buy quote. + dispatch(actions.updateLatestBuyQuote(undefined)); + // reset our buy state + dispatch(actions.setBuyOrderStateNone()); + + if (!_.isUndefined(value) && value.greaterThan(0) && !_.isUndefined(asset)) { + // 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, { + setPending: true, + dispatchErrors: true, + affiliateInfo, + }); + } + }, +}); + +const mergeProps = ( + connectedState: ConnectedState, + connectedDispatch: ConnectedDispatch, + ownProps: SelectedERC20AssetAmountInputProps, +): FinalProps => { + return { + ...ownProps, + asset: connectedState.asset, + value: connectedState.value, + onChange: (value, asset) => { + connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset, connectedState.affiliateInfo); + }, + isDisabled: connectedState.isDisabled, + numberOfAssetsAvailable: connectedState.numberOfAssetsAvailable, + }; +}; + +export const SelectedERC20AssetAmountInput: React.ComponentClass<SelectedERC20AssetAmountInputProps> = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps, +)(ERC20AssetAmountInput); diff --git a/packages/instant/src/data/asset_data_network_mapping.ts b/packages/instant/src/data/asset_data_network_mapping.ts new file mode 100644 index 000000000..4fd0a25ed --- /dev/null +++ b/packages/instant/src/data/asset_data_network_mapping.ts @@ -0,0 +1,66 @@ +import * as _ from 'lodash'; + +import { Network } from '../types'; + +interface AssetDataByNetwork { + [Network.Kovan]?: string; + [Network.Mainnet]?: string; +} + +export const assetDataNetworkMapping: AssetDataByNetwork[] = [ + // ZRX + { + [Network.Mainnet]: '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498', + [Network.Kovan]: '0xf47261b00000000000000000000000002002d3812f58e35f0ea1ffbf80a75a38c32175fa', + }, + // SPANK + { + [Network.Mainnet]: '0xf47261b000000000000000000000000042d6622dece394b54999fbd73d108123806f6a18', + [Network.Kovan]: '0xf47261b00000000000000000000000007c9eee8448f3a7d1193389652d863b27e543272d', + }, + // OMG + { + [Network.Mainnet]: '0xf47261b0000000000000000000000000d26114cd6ee289accf82350c8d8487fedb8a0c07', + [Network.Kovan]: '0xf47261b000000000000000000000000046096d8ec059dbaae2950b30e01634ff0dc652ec', + }, + // MKR + { + [Network.Mainnet]: '0xf47261b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2', + // 0x Kovan MKR + [Network.Kovan]: '0xf47261b00000000000000000000000007b6b10caa9e8e9552ba72638ea5b47c25afea1f3', + }, + // BAT + { + [Network.Mainnet]: '0xf47261b00000000000000000000000000d8775f648430679a709e98d2b0cb6250d2887ef', + [Network.Kovan]: '0xf47261b0000000000000000000000000c87faa7a58f0adf306bad9e7d892fb045a20e5af', + }, + // SNT + { + [Network.Mainnet]: '0xf47261b0000000000000000000000000744d70fdbe2ba4cf95131626614a1763df805b9e', + [Network.Kovan]: '0xf47261b00000000000000000000000009cfe76a718ea75e3e8ce4fc7ad0fef84be70919b', + }, + // MANA + { + [Network.Mainnet]: '0xf47261b00000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc942', + [Network.Kovan]: '0xf47261b0000000000000000000000000c64edfc78321673435fbeebdaaa7f9d755963542', + }, + // GNT + { + [Network.Mainnet]: '0xf47261b0000000000000000000000000a74476443119a942de498590fe1f2454d7d4ac0d', + // 0x Kovan GNT + [Network.Kovan]: '0xf47261b000000000000000000000000031fb614e223706f15d0d3c5f4b08bdf0d5c78623', + }, + // SUB + { + [Network.Mainnet]: '0xf47261b000000000000000000000000012480e24eb5bec1a9d4369cab6a80cad3c0a377a', + }, + // Dentacoin + { + [Network.Mainnet]: '0xf47261b000000000000000000000000008d32b0da63e2C3bcF8019c9c5d849d7a9d791e6', + }, + // REP + { + [Network.Kovan]: '0xf47261b00000000000000000000000008cb3971b8eb709c14616bd556ff6683019e90d9c', + [Network.Mainnet]: '0xf47261b00000000000000000000000001985365e9f78359a9b6ad760e32412f4a445e862', + }, +]; diff --git a/packages/instant/src/data/asset_meta_data_map.ts b/packages/instant/src/data/asset_meta_data_map.ts new file mode 100644 index 000000000..6ecb9af85 --- /dev/null +++ b/packages/instant/src/data/asset_meta_data_map.ts @@ -0,0 +1,204 @@ +import { AssetProxyId, ObjectMap } from '@0x/types'; + +import { AssetMetaData } from '../types'; + +// Map from assetData string to AssetMetaData object +// TODO: import this from somewhere else. +export const assetMetaDataMap: ObjectMap<AssetMetaData> = { + '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#333333', + symbol: 'zrx', + name: '0x', + }, + '0xf47261b000000000000000000000000042d6622dece394b54999fbd73d108123806f6a18': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#EC4F81', + symbol: 'spank', + name: 'Spank', + }, + '0xf47261b0000000000000000000000000d26114cd6ee289accf82350c8d8487fedb8a0c07': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#2458E7', + symbol: 'omg', + name: 'OmiseGo', + }, + '0xf47261b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#68CCBB', + symbol: 'mkr', + name: 'Maker', + }, + '0xf47261b00000000000000000000000000d8775f648430679a709e98d2b0cb6250d2887ef': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#FF5000', + symbol: 'bat', + name: 'Basic Attention Token', + }, + '0xf47261b0000000000000000000000000744d70fdbe2ba4cf95131626614a1763df805b9e': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#4763D7', + symbol: 'snt', + name: 'Status', + }, + '0xf47261b00000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc942': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#f08839', + symbol: 'mana', + name: 'Decentraland', + }, + '0xf47261b00000000000000000000000001985365e9f78359a9b6ad760e32412f4a445e862': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#512D80', + symbol: 'rep', + name: 'Augur', + }, + '0xf47261b00000000000000000000000000abdace70d3790235af448c88547603b945604ea': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#2c3c8c', + symbol: 'dnt', + name: 'district0x', + }, + '0xf47261b000000000000000000000000005f4a42e251f2d52b8ed15e9fedaacfcef1fad27': { + assetProxyId: AssetProxyId.ERC20, + decimals: 12, + primaryColor: '#048998', + symbol: 'zil', + name: 'Zilliqa', + }, + '0xf47261b00000000000000000000000008f8221afbb33998d8584a2b05749ba73c37a938a': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#58BFD6', + symbol: 'req', + name: 'Request Network', + }, + '0xf47261b0000000000000000000000000e0b7927c4af23765cb51314a0e0521a9645f0e2a': { + assetProxyId: AssetProxyId.ERC20, + decimals: 9, + primaryColor: '#BC9952', + symbol: 'dgd', + name: 'DigixDao', + }, + '0xf47261b00000000000000000000000004f3afec4e5a3f2a6a1a411def7d7dfe50ee057bf': { + assetProxyId: AssetProxyId.ERC20, + decimals: 9, + primaryColor: '#DEB564', + symbol: 'dgx', + name: 'Digix Gold Token', + }, + '0xf47261b0000000000000000000000000419d0d8bdd9af5e606ae2232ed285aff190e711b': { + assetProxyId: AssetProxyId.ERC20, + decimals: 8, + primaryColor: '#E40057', + symbol: 'fun', + name: 'FunFair', + }, + '0xf47261b000000000000000000000000041e5560054824ea6b0732e656e3ad64e20e94e45': { + assetProxyId: AssetProxyId.ERC20, + decimals: 8, + primaryColor: '#04bc24', + symbol: 'cvc', + name: 'Civic', + }, + '0xf47261b00000000000000000000000005ca9a71b1d01849c0a95490cc00559717fcf0d1d': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#F7296E', + symbol: 'ae', + name: 'Aeternity', + }, + '0xf47261b0000000000000000000000000408e41876cccdc0f92210600ef50372656052a38': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#233C5A', + symbol: 'ren', + name: 'Republic Protocol', + }, + '0xf47261b0000000000000000000000000514910771af9ca656af840dff83e8264ecf986ca': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#325CD2', + symbol: 'link', + name: 'ChainLink', + }, + '0xf47261b00000000000000000000000006810e776880c02933d47db1b9fc05908e5386b96': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#48A4C0', + symbol: 'gno', + name: 'Gnosis', + }, + '0xf47261b0000000000000000000000000960b236a07cf122663c4303350609a66a7b288c0': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#04a29e', + symbol: 'ant', + name: 'Aragon', + }, + '0xf47261b00000000000000000000000004156d3342d5c385a87d264f90653733592000581': { + assetProxyId: AssetProxyId.ERC20, + decimals: 8, + primaryColor: '#4CABA7', + symbol: 'salt', + name: 'Salt', + }, + '0xf47261b0000000000000000000000000595832f8fc6bf59c85c527fec3740a1b7a361269': { + assetProxyId: AssetProxyId.ERC20, + decimals: 6, + primaryColor: '#5BC9D4', + symbol: 'powr', + name: 'PowerLedger', + }, + '0xf47261b00000000000000000000000008eb24319393716668d768dcec29356ae9cffe285': { + assetProxyId: AssetProxyId.ERC20, + decimals: 8, + primaryColor: '#523CE8', + symbol: 'agi', + name: 'SingularityNET', + }, + '0xf47261b000000000000000000000000039bb259f66e1c59d5abef88375979b4d20d98022': { + assetProxyId: AssetProxyId.ERC20, + decimals: 8, + primaryColor: '#EDB740', + symbol: 'wax', + name: 'WAX', + }, + '0xf47261b0000000000000000000000000beb9ef514a379b997e0798fdcc901ee474b6d9a1': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#333333', + symbol: 'mln', + name: 'Melon', + }, + '0xf47261b000000000000000000000000058b6a8a3302369daec383334672404ee733ab239': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#232D37', + symbol: 'lpt', + name: 'Livepeer', + }, + '0xf47261b000000000000000000000000027054b13b1b798b345b591a4d22e6562d47ea75a': { + assetProxyId: AssetProxyId.ERC20, + decimals: 4, + primaryColor: '#3A74F6', + symbol: 'ast', + name: 'AirSwap', + }, + '0xf47261b000000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#F2B350', + symbol: 'dai', + name: 'Dai Stablecoin', + }, +}; diff --git a/packages/instant/src/index.ts b/packages/instant/src/index.ts index 54059cdad..6e611dae8 100644 --- a/packages/instant/src/index.ts +++ b/packages/instant/src/index.ts @@ -1 +1,2 @@ export { ZeroExInstant, ZeroExInstantProps } from './components/zero_ex_instant'; +export { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './components/zero_ex_instant_overlay'; diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index d4eca177d..0274db30c 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -1,10 +1,56 @@ +import * as _ from 'lodash'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { ZeroExInstant } from './index'; +import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR, INJECTED_DIV_CLASS, INJECTED_DIV_ID } from './constants'; +import { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './index'; +import { assert } from './util/assert'; -export interface ZeroExInstantOptions {} - -export const render = (props: ZeroExInstantOptions, selector: string = '#zeroExInstantContainer') => { - ReactDOM.render(React.createElement(ZeroExInstant, props), document.querySelector(selector)); +export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFAULT_ZERO_EX_CONTAINER_SELECTOR) => { + assert.isValidOrderSource('orderSource', props.orderSource); + if (!_.isUndefined(props.defaultSelectedAssetData)) { + assert.isHexString('defaultSelectedAssetData', props.defaultSelectedAssetData); + } + if (!_.isUndefined(props.additionalAssetMetaDataMap)) { + assert.isValidAssetMetaDataMap('props.additionalAssetMetaDataMap', props.additionalAssetMetaDataMap); + } + if (!_.isUndefined(props.defaultAssetBuyAmount)) { + assert.isNumber('props.defaultAssetBuyAmount', props.defaultAssetBuyAmount); + } + if (!_.isUndefined(props.networkId)) { + assert.isNumber('props.networkId', props.networkId); + } + if (!_.isUndefined(props.availableAssetDatas)) { + assert.areValidAssetDatas('availableAssetDatas', props.availableAssetDatas); + } + if (!_.isUndefined(props.onClose)) { + assert.isFunction('props.onClose', props.onClose); + } + if (!_.isUndefined(props.zIndex)) { + assert.isNumber('props.zIndex', props.zIndex); + } + if (!_.isUndefined(props.affiliateInfo)) { + assert.isValidAffiliateInfo('props.affiliateInfo', props.affiliateInfo); + } + if (!_.isUndefined(props.provider)) { + assert.isWeb3Provider('props.provider', props.provider); + } + assert.isString('selector', selector); + const appendToIfExists = document.querySelector(selector); + assert.assert(!_.isNull(appendToIfExists), `Could not find div with selector: ${selector}`); + const appendTo = appendToIfExists as Element; + const injectedDiv = document.createElement('div'); + injectedDiv.setAttribute('id', INJECTED_DIV_ID); + injectedDiv.setAttribute('class', INJECTED_DIV_CLASS); + appendTo.appendChild(injectedDiv); + const instantOverlayProps = { + ...props, + onClose: () => { + appendTo.removeChild(injectedDiv); + if (!_.isUndefined(props.onClose)) { + props.onClose(); + } + }, + }; + ReactDOM.render(React.createElement(ZeroExInstantOverlay, instantOverlayProps), injectedDiv); }; diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts new file mode 100644 index 000000000..77e3dec12 --- /dev/null +++ b/packages/instant/src/redux/actions.ts @@ -0,0 +1,75 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { ActionsUnion, AddressAndEthBalanceInWei, Asset, StandardSlidingPanelContent } from '../types'; + +export interface PlainAction<T extends string> { + type: T; +} + +export interface ActionWithPayload<T extends string, P> extends PlainAction<T> { + data: P; +} + +export type Action = ActionsUnion<typeof actions>; + +function createAction<T extends string>(type: T): PlainAction<T>; +function createAction<T extends string, P>(type: T, data: P): ActionWithPayload<T, P>; +function createAction<T extends string, P>(type: T, data?: P): PlainAction<T> | ActionWithPayload<T, P> { + return _.isUndefined(data) ? { type } : { type, data }; +} + +export enum ActionTypes { + SET_ACCOUNT_STATE_LOADING = 'SET_ACCOUNT_STATE_LOADING', + SET_ACCOUNT_STATE_LOCKED = 'SET_ACCOUNT_STATE_LOCKED', + SET_ACCOUNT_STATE_READY = 'SET_ACCOUNT_STATE_READY', + UPDATE_ACCOUNT_ETH_BALANCE = 'UPDATE_ACCOUNT_ETH_BALANCE', + UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE', + UPDATE_SELECTED_ASSET_UNIT_AMOUNT = 'UPDATE_SELECTED_ASSET_UNIT_AMOUNT', + SET_BUY_ORDER_STATE_NONE = 'SET_BUY_ORDER_STATE_NONE', + SET_BUY_ORDER_STATE_VALIDATING = 'SET_BUY_ORDER_STATE_VALIDATING', + SET_BUY_ORDER_STATE_PROCESSING = 'SET_BUY_ORDER_STATE_PROCESSING', + SET_BUY_ORDER_STATE_FAILURE = 'SET_BUY_ORDER_STATE_FAILURE', + SET_BUY_ORDER_STATE_SUCCESS = 'SET_BUY_ORDER_STATE_SUCCESS', + UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE', + UPDATE_SELECTED_ASSET = 'UPDATE_SELECTED_ASSET', + SET_AVAILABLE_ASSETS = 'SET_AVAILABLE_ASSETS', + SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING', + SET_QUOTE_REQUEST_STATE_FAILURE = 'SET_QUOTE_REQUEST_STATE_FAILURE', + SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE', + HIDE_ERROR = 'HIDE_ERROR', + CLEAR_ERROR = 'CLEAR_ERROR', + RESET_AMOUNT = 'RESET_AMOUNT', + OPEN_STANDARD_SLIDING_PANEL = 'OPEN_STANDARD_SLIDING_PANEL', + CLOSE_STANDARD_SLIDING_PANEL = 'CLOSE_STANDARD_SLIDING_PANEL', +} + +export const actions = { + setAccountStateLoading: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOADING), + setAccountStateLocked: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOCKED), + setAccountStateReady: (address: string) => createAction(ActionTypes.SET_ACCOUNT_STATE_READY, address), + updateAccountEthBalance: (addressAndBalance: AddressAndEthBalanceInWei) => + createAction(ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE, addressAndBalance), + updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), + updateSelectedAssetAmount: (amount?: BigNumber) => + createAction(ActionTypes.UPDATE_SELECTED_ASSET_UNIT_AMOUNT, amount), + setBuyOrderStateNone: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_NONE), + setBuyOrderStateValidating: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_VALIDATING), + setBuyOrderStateProcessing: (txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => + createAction(ActionTypes.SET_BUY_ORDER_STATE_PROCESSING, { txHash, startTimeUnix, expectedEndTimeUnix }), + setBuyOrderStateFailure: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_FAILURE, txHash), + setBuyOrderStateSuccess: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_SUCCESS, txHash), + updateLatestBuyQuote: (buyQuote?: BuyQuote) => createAction(ActionTypes.UPDATE_LATEST_BUY_QUOTE, buyQuote), + updateSelectedAsset: (asset: Asset) => createAction(ActionTypes.UPDATE_SELECTED_ASSET, asset), + setAvailableAssets: (availableAssets: Asset[]) => createAction(ActionTypes.SET_AVAILABLE_ASSETS, availableAssets), + setQuoteRequestStatePending: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING), + setQuoteRequestStateFailure: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_FAILURE), + setErrorMessage: (errorMessage: string) => createAction(ActionTypes.SET_ERROR_MESSAGE, errorMessage), + hideError: () => createAction(ActionTypes.HIDE_ERROR), + clearError: () => createAction(ActionTypes.CLEAR_ERROR), + resetAmount: () => createAction(ActionTypes.RESET_AMOUNT), + openStandardSlidingPanel: (content: StandardSlidingPanelContent) => + createAction(ActionTypes.OPEN_STANDARD_SLIDING_PANEL, content), + closeStandardSlidingPanel: () => createAction(ActionTypes.CLOSE_STANDARD_SLIDING_PANEL), +}; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts new file mode 100644 index 000000000..5d30388b8 --- /dev/null +++ b/packages/instant/src/redux/async_data.ts @@ -0,0 +1,104 @@ +import { AssetProxyId } from '@0x/types'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import { Dispatch } from 'redux'; + +import { BIG_NUMBER_ZERO } from '../constants'; +import { AccountState, ERC20Asset, OrderProcessState, ProviderState } from '../types'; +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 { actions } from './actions'; +import { State } from './reducer'; + +export const asyncData = { + fetchEthPriceAndDispatchToStore: async (dispatch: Dispatch) => { + try { + const ethUsdPrice = await coinbaseApi.getEthUsdPrice(); + dispatch(actions.updateEthUsdPrice(ethUsdPrice)); + } catch (e) { + const errorMessage = 'Error fetching ETH/USD price'; + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + } + }, + fetchAvailableAssetDatasAndDispatchToStore: async (state: State, dispatch: Dispatch) => { + const { providerState, assetMetaDataMap, network } = state; + const assetBuyer = providerState.assetBuyer; + try { + const assetDatas = await assetBuyer.getAvailableAssetDatasAsync(); + const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, 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([])); + } + }, + fetchAccountInfoAndDispatchToStore: async ( + providerState: ProviderState, + dispatch: Dispatch, + shouldAttemptUnlock: boolean = false, + shouldSetToLoading: boolean = false, + ) => { + const web3Wrapper = providerState.web3Wrapper; + const provider = providerState.provider; + if (shouldSetToLoading && providerState.account.state !== AccountState.Loading) { + dispatch(actions.setAccountStateLoading()); + } + let availableAddresses: string[]; + try { + // TODO(bmillman): Add support at the web3Wrapper level for calling `eth_requestAccounts` instead of calling enable here + const isPrivacyModeEnabled = !_.isUndefined((provider as any).enable); + availableAddresses = + isPrivacyModeEnabled && shouldAttemptUnlock + ? await (provider as any).enable() + : await web3Wrapper.getAvailableAddressesAsync(); + } catch (e) { + dispatch(actions.setAccountStateLocked()); + return; + } + if (!_.isEmpty(availableAddresses)) { + const activeAddress = availableAddresses[0]; + dispatch(actions.setAccountStateReady(activeAddress)); + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAccountBalanceAndDispatchToStore(activeAddress, providerState.web3Wrapper, dispatch); + } else { + dispatch(actions.setAccountStateLocked()); + } + }, + fetchAccountBalanceAndDispatchToStore: async (address: string, web3Wrapper: Web3Wrapper, dispatch: Dispatch) => { + try { + const ethBalanceInWei = await web3Wrapper.getBalanceInWeiAsync(address); + dispatch(actions.updateAccountEthBalance({ address, ethBalanceInWei })); + } catch (e) { + // leave balance as is + return; + } + }, + fetchCurrentBuyQuoteAndDispatchToStore: async ( + state: State, + dispatch: Dispatch, + options: { updateSilently: boolean }, + ) => { + const { buyOrderState, providerState, selectedAsset, selectedAssetUnitAmount, affiliateInfo } = state; + const assetBuyer = providerState.assetBuyer; + if ( + !_.isUndefined(selectedAssetUnitAmount) && + !_.isUndefined(selectedAsset) && + buyOrderState.processState === OrderProcessState.None && + selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20 + ) { + await buyQuoteUpdater.updateBuyQuoteAsync( + assetBuyer, + dispatch, + selectedAsset as ERC20Asset, + selectedAssetUnitAmount, + { setPending: !options.updateSilently, dispatchErrors: !options.updateSilently, affiliateInfo }, + ); + } + }, +}; diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts new file mode 100644 index 000000000..dfc2b89f3 --- /dev/null +++ b/packages/instant/src/redux/reducer.ts @@ -0,0 +1,288 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId, ObjectMap } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; + +import { LOADING_ACCOUNT, LOCKED_ACCOUNT } from '../constants'; +import { assetMetaDataMap } from '../data/asset_meta_data_map'; +import { + Account, + AccountReady, + AccountState, + AffiliateInfo, + Asset, + AssetMetaData, + AsyncProcessState, + DisplayStatus, + Network, + OrderProcessState, + OrderState, + ProviderState, + StandardSlidingPanelContent, + StandardSlidingPanelSettings, +} from '../types'; + +import { Action, ActionTypes } from './actions'; + +// State that is required and we have defaults for, before props are passed in +export interface DefaultState { + network: Network; + assetMetaDataMap: ObjectMap<AssetMetaData>; + buyOrderState: OrderState; + latestErrorDisplayStatus: DisplayStatus; + quoteRequestState: AsyncProcessState; + standardSlidingPanelSettings: StandardSlidingPanelSettings; +} + +// State that is required but needs to be derived from the props +interface PropsDerivedState { + providerState: ProviderState; +} + +// State that is optional +interface OptionalState { + selectedAsset: Asset; + availableAssets: Asset[]; + selectedAssetUnitAmount: BigNumber; + ethUsdPrice: BigNumber; + latestBuyQuote: BuyQuote; + latestErrorMessage: string; + affiliateInfo: AffiliateInfo; +} + +export type State = DefaultState & PropsDerivedState & Partial<OptionalState>; + +export const DEFAULT_STATE: DefaultState = { + network: Network.Mainnet, + assetMetaDataMap, + buyOrderState: { processState: OrderProcessState.None }, + latestErrorDisplayStatus: DisplayStatus.Hidden, + quoteRequestState: AsyncProcessState.None, + standardSlidingPanelSettings: { + animationState: 'none', + content: StandardSlidingPanelContent.None, + }, +}; + +export const createReducer = (initialState: State) => { + const reducer = (state: State = initialState, action: Action): State => { + switch (action.type) { + case ActionTypes.SET_ACCOUNT_STATE_LOADING: + return reduceStateWithAccount(state, LOADING_ACCOUNT); + case ActionTypes.SET_ACCOUNT_STATE_LOCKED: + return reduceStateWithAccount(state, LOCKED_ACCOUNT); + case ActionTypes.SET_ACCOUNT_STATE_READY: { + const address = action.data; + let newAccount: AccountReady = { + state: AccountState.Ready, + address, + }; + const currentAccount = state.providerState.account; + if (currentAccount.state === AccountState.Ready && currentAccount.address === address) { + newAccount = { + ...newAccount, + ethBalanceInWei: currentAccount.ethBalanceInWei, + }; + } + return reduceStateWithAccount(state, newAccount); + } + case ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE: { + const { address, ethBalanceInWei } = action.data; + const currentAccount = state.providerState.account; + if (currentAccount.state !== AccountState.Ready || currentAccount.address !== address) { + return state; + } else { + const newAccount: AccountReady = { + ...currentAccount, + ethBalanceInWei, + }; + return reduceStateWithAccount(state, newAccount); + } + } + case ActionTypes.UPDATE_ETH_USD_PRICE: + return { + ...state, + ethUsdPrice: action.data, + }; + case ActionTypes.UPDATE_SELECTED_ASSET_UNIT_AMOUNT: + return { + ...state, + selectedAssetUnitAmount: action.data, + }; + case ActionTypes.UPDATE_LATEST_BUY_QUOTE: + const newBuyQuoteIfExists = action.data; + const shouldUpdate = + _.isUndefined(newBuyQuoteIfExists) || doesBuyQuoteMatchState(newBuyQuoteIfExists, state); + if (shouldUpdate) { + return { + ...state, + latestBuyQuote: newBuyQuoteIfExists, + quoteRequestState: AsyncProcessState.Success, + }; + } else { + return state; + } + case ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.Pending, + }; + case ActionTypes.SET_QUOTE_REQUEST_STATE_FAILURE: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.Failure, + }; + case ActionTypes.SET_BUY_ORDER_STATE_NONE: + return { + ...state, + buyOrderState: { processState: OrderProcessState.None }, + }; + case ActionTypes.SET_BUY_ORDER_STATE_VALIDATING: + return { + ...state, + buyOrderState: { processState: OrderProcessState.Validating }, + }; + case ActionTypes.SET_BUY_ORDER_STATE_PROCESSING: + const processingData = action.data; + const { startTimeUnix, expectedEndTimeUnix } = processingData; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.Processing, + txHash: processingData.txHash, + progress: { + startTimeUnix, + expectedEndTimeUnix, + }, + }, + }; + case ActionTypes.SET_BUY_ORDER_STATE_FAILURE: + const failureTxHash = action.data; + if ('txHash' in state.buyOrderState) { + if (state.buyOrderState.txHash === failureTxHash) { + const { txHash, progress } = state.buyOrderState; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.Failure, + txHash, + progress, + }, + }; + } + } + return state; + case ActionTypes.SET_BUY_ORDER_STATE_SUCCESS: + const successTxHash = action.data; + if ('txHash' in state.buyOrderState) { + if (state.buyOrderState.txHash === successTxHash) { + const { txHash, progress } = state.buyOrderState; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.Success, + txHash, + progress, + }, + }; + } + } + return state; + case ActionTypes.SET_ERROR_MESSAGE: + return { + ...state, + latestErrorMessage: action.data, + latestErrorDisplayStatus: DisplayStatus.Present, + }; + case ActionTypes.HIDE_ERROR: + return { + ...state, + latestErrorDisplayStatus: DisplayStatus.Hidden, + }; + case ActionTypes.CLEAR_ERROR: + return { + ...state, + latestErrorMessage: undefined, + latestErrorDisplayStatus: DisplayStatus.Hidden, + }; + case ActionTypes.UPDATE_SELECTED_ASSET: + return { + ...state, + selectedAsset: action.data, + }; + case ActionTypes.RESET_AMOUNT: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.None, + buyOrderState: { processState: OrderProcessState.None }, + selectedAssetUnitAmount: undefined, + }; + case ActionTypes.SET_AVAILABLE_ASSETS: + return { + ...state, + availableAssets: action.data, + }; + case ActionTypes.OPEN_STANDARD_SLIDING_PANEL: + return { + ...state, + standardSlidingPanelSettings: { + content: action.data, + animationState: 'slidIn', + }, + }; + case ActionTypes.CLOSE_STANDARD_SLIDING_PANEL: + return { + ...state, + standardSlidingPanelSettings: { + content: state.standardSlidingPanelSettings.content, + animationState: 'slidOut', + }, + }; + default: + return state; + } + }; + return reducer; +}; + +const reduceStateWithAccount = (state: State, account: Account) => { + const oldProviderState = state.providerState; + const newProviderState: ProviderState = { + ...oldProviderState, + account, + }; + return { + ...state, + providerState: newProviderState, + }; +}; + +const doesBuyQuoteMatchState = (buyQuote: BuyQuote, state: State): boolean => { + const selectedAssetIfExists = state.selectedAsset; + const selectedAssetUnitAmountIfExists = state.selectedAssetUnitAmount; + // if no selectedAsset or selectedAssetAmount exists on the current state, return false + if (_.isUndefined(selectedAssetIfExists) || _.isUndefined(selectedAssetUnitAmountIfExists)) { + return false; + } + // if buyQuote's assetData does not match that of the current selected asset, return false + if (selectedAssetIfExists.assetData !== buyQuote.assetData) { + return false; + } + // if ERC20 and buyQuote's assetBuyAmount does not match selectedAssetAmount, return false + // if ERC721, return true + const selectedAssetMetaData = selectedAssetIfExists.metaData; + if (selectedAssetMetaData.assetProxyId === AssetProxyId.ERC20) { + const selectedAssetAmountBaseUnits = Web3Wrapper.toBaseUnitAmount( + selectedAssetUnitAmountIfExists, + selectedAssetMetaData.decimals, + ); + const doesAssetAmountMatch = selectedAssetAmountBaseUnits.eq(buyQuote.assetBuyAmount); + return doesAssetAmountMatch; + } else { + return true; + } +}; diff --git a/packages/instant/src/redux/store.ts b/packages/instant/src/redux/store.ts new file mode 100644 index 000000000..20710765d --- /dev/null +++ b/packages/instant/src/redux/store.ts @@ -0,0 +1,14 @@ +import * as _ from 'lodash'; +import { createStore, Store as ReduxStore } from 'redux'; +import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly'; + +import { createReducer, State } from './reducer'; + +export type Store = ReduxStore<State>; + +export const store = { + create: (initialState: State): Store => { + const reducer = createReducer(initialState); + return createStore(reducer, initialState, devToolsEnhancer({})); + }, +}; diff --git a/packages/instant/src/style/fonts.ts b/packages/instant/src/style/fonts.ts new file mode 100644 index 000000000..92450502d --- /dev/null +++ b/packages/instant/src/style/fonts.ts @@ -0,0 +1,10 @@ +export const fonts = { + include: () => { + // Inject the inter-ui font into the page + const appendTo = document.head || document.getElementsByTagName('head')[0] || document.body; + const style = document.createElement('style'); + style.type = 'text/css'; + style.appendChild(document.createTextNode(`@import url('https://rsms.me/inter/inter-ui.css')`)); + appendTo.appendChild(style); + }, +}; diff --git a/packages/instant/src/style/media.ts b/packages/instant/src/style/media.ts new file mode 100644 index 000000000..bbf376694 --- /dev/null +++ b/packages/instant/src/style/media.ts @@ -0,0 +1,51 @@ +import { InterpolationValue } from 'styled-components'; + +import { css } from './theme'; + +export enum ScreenWidths { + Sm = 40, + Md = 52, + Lg = 64, +} + +export const generateMediaWrapper = (screenWidth: ScreenWidths) => (...args: any[]) => css` + @media (max-width: ${screenWidth}em) { + ${css.apply(css, args)}; + } +`; + +export const media = { + small: generateMediaWrapper(ScreenWidths.Sm), + medium: generateMediaWrapper(ScreenWidths.Md), + large: generateMediaWrapper(ScreenWidths.Lg), +}; + +export interface ScreenSpecification<T> { + default: T; + sm?: T; + md?: T; + lg?: T; +} +export type OptionallyScreenSpecific<T> = T | ScreenSpecification<T>; +export type MediaChoice = OptionallyScreenSpecific<string>; +/** + * Given a css property name and a OptionallyScreenSpecific value, + * generates css properties with screen-specific viewport styling + */ +export function stylesForMedia<T extends string | number>( + cssPropertyName: string, + choice: OptionallyScreenSpecific<T>, +): InterpolationValue[] { + if (typeof choice === 'object') { + return css` + ${cssPropertyName}: ${choice.default}; + ${choice.lg && media.large`${cssPropertyName}: ${choice.lg}`} + ${choice.md && media.medium`${cssPropertyName}: ${choice.md}`} + ${choice.sm && media.small`${cssPropertyName}: ${choice.sm}`} + `; + } else { + return css` + ${cssPropertyName}: ${choice}; + `; + } +} diff --git a/packages/instant/src/style/theme.ts b/packages/instant/src/style/theme.ts new file mode 100644 index 000000000..a0751286b --- /dev/null +++ b/packages/instant/src/style/theme.ts @@ -0,0 +1,48 @@ +import * as styledComponents from 'styled-components'; + +const { default: styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider } = styledComponents; + +export type Theme = { [key in ColorOption]: string }; + +export enum ColorOption { + primaryColor = 'primaryColor', + black = 'black', + lightGrey = 'lightGrey', + grey = 'grey', + feintGrey = 'feintGrey', + lightestGrey = 'lightestGrey', + darkGrey = 'darkGrey', + white = 'white', + lightOrange = 'lightOrange', + darkOrange = 'darkOrange', + green = 'green', + red = 'red', + darkBlue = 'darkBlue', + lightBlue = 'lightBlue', +} + +export const theme: Theme = { + primaryColor: '#333', + black: 'black', + lightGrey: '#999999', + grey: '#666666', + feintGrey: '#DEDEDE', + lightestGrey: '#EEEEEE', + darkGrey: '#333333', + white: 'white', + lightOrange: '#F9F2ED', + darkOrange: '#F2994C', + green: '#3CB34F', + red: '#D00000', + darkBlue: '#135df6', + lightBlue: '#F2F7FF', +}; + +export const transparentWhite = 'rgba(255,255,255,0.3)'; +export const completelyTransparent = 'rga(0, 0, 0, 0)'; + +export const generateOverlayBlack = (opacity = 0.6) => { + return `rgba(0, 0, 0, ${opacity})`; +}; + +export { styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider }; diff --git a/packages/instant/src/style/util.ts b/packages/instant/src/style/util.ts new file mode 100644 index 000000000..3e38c4a7d --- /dev/null +++ b/packages/instant/src/style/util.ts @@ -0,0 +1,11 @@ +import { ObjectMap } from '@0x/types'; +import * as _ from 'lodash'; + +export const cssRuleIfExists = (props: ObjectMap<any>, rule: string): string => { + const camelCaseRule = _.camelCase(rule); + const ruleValueIfExists = props[camelCaseRule]; + if (!_.isUndefined(ruleValueIfExists)) { + return `${rule}: ${ruleValueIfExists};`; + } + return ''; +}; diff --git a/packages/instant/src/style/z_index.ts b/packages/instant/src/style/z_index.ts new file mode 100644 index 000000000..ba2d27a17 --- /dev/null +++ b/packages/instant/src/style/z_index.ts @@ -0,0 +1,9 @@ +export const zIndex = { + errorPopBehind: 10, + mainContainer: 20, + dropdownItems: 30, + panel: 40, + containerOverlay: 45, + errorPopup: 50, + overlayDefault: 100, +}; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts new file mode 100644 index 000000000..67f21a396 --- /dev/null +++ b/packages/instant/src/types.ts @@ -0,0 +1,167 @@ +import { AssetBuyer, BigNumber } from '@0x/asset-buyer'; +import { AssetProxyId, ObjectMap, SignedOrder } from '@0x/types'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import { Provider } from 'ethereum-types'; + +// Reusable +export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; +export type Maybe<T> = T | undefined; +export enum AsyncProcessState { + None = 'NONE', + Pending = 'PENDING', + Success = 'SUCCESS', + Failure = 'FAILURE', +} + +export enum OrderProcessState { + None = 'NONE', + Validating = 'VALIDATING', + Processing = 'PROCESSING', + Success = 'SUCCESS', + Failure = 'FAILURE', +} + +export interface SimulatedProgress { + startTimeUnix: number; + expectedEndTimeUnix: number; +} + +interface OrderStatePreTx { + processState: OrderProcessState.None | OrderProcessState.Validating; +} +interface OrderStatePostTx { + processState: OrderProcessState.Processing | OrderProcessState.Success | OrderProcessState.Failure; + txHash: string; + progress: SimulatedProgress; +} +export type OrderState = OrderStatePreTx | OrderStatePostTx; + +export enum DisplayStatus { + Present, + Hidden, +} + +export type FunctionType = (...args: any[]) => any; +export type ActionCreatorsMapObject = ObjectMap<FunctionType>; +export type ActionsUnion<A extends ActionCreatorsMapObject> = ReturnType<A[keyof A]>; + +export interface ERC20AssetMetaData { + assetProxyId: AssetProxyId.ERC20; + decimals: number; + primaryColor?: string; + symbol: string; + name: string; +} + +export interface ERC721AssetMetaData { + assetProxyId: AssetProxyId.ERC721; + name: string; + imageUrl?: string; + primaryColor?: string; +} + +export type AssetMetaData = ERC20AssetMetaData | ERC721AssetMetaData; + +export interface ERC20Asset { + assetData: string; + metaData: ERC20AssetMetaData; +} + +export interface ERC721Asset { + assetData: string; + metaData: ERC721AssetMetaData; +} + +export interface Asset { + assetData: string; + metaData: AssetMetaData; +} + +export enum Network { + Kovan = 42, + Mainnet = 1, +} + +export enum ZeroExInstantError { + AssetMetaDataNotAvailable = 'ASSET_META_DATA_NOT_AVAILABLE', + InsufficientETH = 'INSUFFICIENT_ETH', +} + +export type SimpleHandler = () => void; + +export interface AffiliateInfo { + feeRecipient: string; + feePercentage: number; +} + +export interface ProviderState { + name: string; + provider: Provider; + assetBuyer: AssetBuyer; + web3Wrapper: Web3Wrapper; + account: Account; +} + +export enum AccountState { + None = 'NONE,', + Loading = 'LOADING', + Ready = 'READY', + Locked = 'LOCKED', +} + +export interface AccountReady { + state: AccountState.Ready; + address: string; + ethBalanceInWei?: BigNumber; +} +export interface AccountNotReady { + state: AccountState.None | AccountState.Loading | AccountState.Locked; +} + +export type Account = AccountReady | AccountNotReady; + +export type OrderSource = string | SignedOrder[]; + +export interface AddressAndEthBalanceInWei { + address: string; + ethBalanceInWei: BigNumber; +} + +export type SlideAnimationState = 'slidIn' | 'slidOut' | 'none'; + +export enum StandardSlidingPanelContent { + None = 'NONE', + InstallWallet = 'INSTALL_WALLET', +} + +export interface StandardSlidingPanelSettings { + animationState: SlideAnimationState; + content: StandardSlidingPanelContent; +} + +export enum Browser { + Chrome = 'CHROME', + Firefox = 'FIREFOX', + Opera = 'OPERA', + Safari = 'SAFARI', + Edge = 'EDGE', + Other = 'OTHER', +} + +export enum OperatingSystem { + Android = 'ANDROID', + iOS = 'IOS', + Mac = 'MAC', + Windows = 'WINDOWS', + WindowsPhone = 'WINDOWS_PHONE', + Linux = 'LINUX', + Other = 'OTHER', +} + +export enum ProviderType { + Parity = 'PARITY', + MetaMask = 'META_MASK', + Mist = 'MIST', + CoinbaseWallet = 'COINBASE_WALLET', + Cipher = 'CIPHER', +} diff --git a/packages/instant/src/util/address.ts b/packages/instant/src/util/address.ts new file mode 100644 index 000000000..b21863a8e --- /dev/null +++ b/packages/instant/src/util/address.ts @@ -0,0 +1,6 @@ +import { Web3Wrapper } from '@0x/web3-wrapper'; + +export const getBestAddress = async (web3Wrapper: Web3Wrapper): Promise<string | undefined> => { + const addresses = await web3Wrapper.getAvailableAddressesAsync(); + return addresses[0]; +}; diff --git a/packages/instant/src/util/assert.ts b/packages/instant/src/util/assert.ts new file mode 100644 index 000000000..971c1eb96 --- /dev/null +++ b/packages/instant/src/util/assert.ts @@ -0,0 +1,55 @@ +import { assert as sharedAssert } from '@0x/assert'; +import { schemas } from '@0x/json-schemas'; +import { assetDataUtils } from '@0x/order-utils'; +import { AssetProxyId, ObjectMap, SignedOrder } from '@0x/types'; +import * as _ from 'lodash'; + +import { AffiliateInfo, AssetMetaData } from '../types'; + +export const assert = { + ...sharedAssert, + isValidOrderSource(variableName: string, orderSource: string | SignedOrder[]): void { + if (_.isString(orderSource)) { + sharedAssert.isUri(variableName, orderSource); + return; + } + sharedAssert.doesConformToSchema(variableName, orderSource, schemas.signedOrdersSchema); + }, + areValidAssetDatas(variableName: string, assetDatas: string[]): void { + _.forEach(assetDatas, (assetData, index) => assert.isHexString(`${variableName}[${index}]`, assetData)); + }, + isValidAssetMetaDataMap(variableName: string, metaDataMap: ObjectMap<AssetMetaData>): void { + _.forEach(metaDataMap, (metaData, assetData) => { + assert.isHexString(`key ${assetData} of ${variableName}`, assetData); + assert.isValidAssetMetaData(`${variableName}.${assetData}`, metaData); + const assetDataProxyId = assetDataUtils.decodeAssetProxyId(assetData); + assert.assert( + metaData.assetProxyId === assetDataProxyId, + `Expected meta data for assetData ${assetData} to have asset proxy id of ${assetDataProxyId}, but instead got ${ + metaData.assetProxyId + }`, + ); + }); + }, + isValidAssetMetaData(variableName: string, metaData: AssetMetaData): void { + assert.isHexString(`${variableName}.assetProxyId`, metaData.assetProxyId); + if (!_.isUndefined(metaData.primaryColor)) { + assert.isString(`${variableName}.primaryColor`, metaData.primaryColor); + } + if (metaData.assetProxyId === AssetProxyId.ERC20) { + assert.isNumber(`${variableName}.decimals`, metaData.decimals); + assert.isString(`${variableName}.symbol`, metaData.symbol); + } else if (metaData.assetProxyId === AssetProxyId.ERC721) { + assert.isString(`${variableName}.name`, metaData.name); + assert.isUri(`${variableName}.imageUrl`, metaData.imageUrl); + } + }, + isValidAffiliateInfo(variableName: string, affiliateInfo: AffiliateInfo): void { + assert.isETHAddressHex(`${variableName}.recipientAddress`, affiliateInfo.feeRecipient); + assert.isNumber(`${variableName}.percentage`, affiliateInfo.feePercentage); + assert.assert( + affiliateInfo.feePercentage >= 0 && affiliateInfo.feePercentage <= 0.05, + `Expected ${variableName}.percentage to be between 0 and 0.05, but is ${affiliateInfo.feePercentage}`, + ); + }, +}; diff --git a/packages/instant/src/util/asset.ts b/packages/instant/src/util/asset.ts new file mode 100644 index 000000000..40560d3eb --- /dev/null +++ b/packages/instant/src/util/asset.ts @@ -0,0 +1,109 @@ +import { AssetProxyId, ObjectMap } from '@0x/types'; +import * as _ from 'lodash'; + +import { assetDataNetworkMapping } from '../data/asset_data_network_mapping'; +import { Asset, AssetMetaData, ERC20Asset, Network, ZeroExInstantError } from '../types'; + +export const assetUtils = { + createAssetsFromAssetDatas: ( + assetDatas: string[], + assetMetaDataMap: ObjectMap<AssetMetaData>, + network: Network, + ): Asset[] => { + const arrayOfAssetOrUndefined = _.map(assetDatas, assetData => + assetUtils.createAssetFromAssetDataIfExists(assetData, assetMetaDataMap, network), + ); + return _.compact(arrayOfAssetOrUndefined); + }, + createAssetFromAssetDataIfExists: ( + assetData: string, + assetMetaDataMap: ObjectMap<AssetMetaData>, + network: Network, + ): Asset | undefined => { + const metaData = assetUtils.getMetaDataIfExists(assetData, assetMetaDataMap, network); + if (_.isUndefined(metaData)) { + return; + } + return { + assetData, + metaData, + }; + }, + createAssetFromAssetDataOrThrow: ( + assetData: string, + assetMetaDataMap: ObjectMap<AssetMetaData>, + network: Network, + ): Asset => { + return { + assetData, + metaData: assetUtils.getMetaDataOrThrow(assetData, assetMetaDataMap, network), + }; + }, + getMetaDataOrThrow: (assetData: string, metaDataMap: ObjectMap<AssetMetaData>, network: Network): AssetMetaData => { + const metaDataIfExists = assetUtils.getMetaDataIfExists(assetData, metaDataMap, network); + if (_.isUndefined(metaDataIfExists)) { + throw new Error(ZeroExInstantError.AssetMetaDataNotAvailable); + } + return metaDataIfExists; + }, + getMetaDataIfExists: ( + assetData: string, + metaDataMap: ObjectMap<AssetMetaData>, + network: Network, + ): AssetMetaData | undefined => { + let mainnetAssetData: string | undefined = assetData; + if (network !== Network.Mainnet) { + const mainnetAssetDataIfExists = assetUtils.getAssociatedAssetDataIfExists( + assetData.toLowerCase(), + network, + ); + // Just so we don't fail in the case where we are on a non-mainnet network, + // but pass in a valid mainnet assetData. + mainnetAssetData = mainnetAssetDataIfExists || assetData; + } + if (_.isUndefined(mainnetAssetData)) { + return; + } + const metaData = metaDataMap[mainnetAssetData.toLowerCase()]; + if (_.isUndefined(metaData)) { + return; + } + return metaData; + }, + bestNameForAsset: (asset?: Asset, defaultName: string = '???'): string => { + if (_.isUndefined(asset)) { + return defaultName; + } + const metaData = asset.metaData; + switch (metaData.assetProxyId) { + case AssetProxyId.ERC20: + return metaData.symbol.toUpperCase(); + case AssetProxyId.ERC721: + return metaData.name; + } + }, + formattedSymbolForAsset: (asset?: ERC20Asset, defaultName: string = '???'): string => { + if (_.isUndefined(asset)) { + return defaultName; + } + const symbol = asset.metaData.symbol; + if (symbol.length <= 5) { + return symbol; + } + return `${symbol.slice(0, 3)}…`; + }, + getAssociatedAssetDataIfExists: (assetData: string, network: Network): string | undefined => { + const assetDataGroupIfExists = _.find(assetDataNetworkMapping, value => value[network] === assetData); + if (_.isUndefined(assetDataGroupIfExists)) { + return; + } + return assetDataGroupIfExists[Network.Mainnet]; + }, + getERC20AssetsFromAssets: (assets: Asset[]): ERC20Asset[] => { + const erc20sOrUndefined = _.map( + assets, + asset => (asset.metaData.assetProxyId === AssetProxyId.ERC20 ? (asset as ERC20Asset) : undefined), + ); + return _.compact(erc20sOrUndefined); + }, +}; diff --git a/packages/instant/src/util/asset_buyer_factory.ts b/packages/instant/src/util/asset_buyer_factory.ts new file mode 100644 index 000000000..5ba46223c --- /dev/null +++ b/packages/instant/src/util/asset_buyer_factory.ts @@ -0,0 +1,17 @@ +import { AssetBuyer, AssetBuyerOpts } from '@0x/asset-buyer'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { Network, OrderSource } from '../types'; + +export const assetBuyerFactory = { + getAssetBuyer: (provider: Provider, orderSource: OrderSource, network: Network): AssetBuyer => { + const assetBuyerOptions: Partial<AssetBuyerOpts> = { + networkId: network, + }; + const assetBuyer = _.isString(orderSource) + ? AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl(provider, orderSource, assetBuyerOptions) + : AssetBuyer.getAssetBuyerForProvidedOrders(provider, orderSource, assetBuyerOptions); + return assetBuyer; + }, +}; diff --git a/packages/instant/src/util/buy_quote_updater.ts b/packages/instant/src/util/buy_quote_updater.ts new file mode 100644 index 000000000..2fd16d781 --- /dev/null +++ b/packages/instant/src/util/buy_quote_updater.ts @@ -0,0 +1,62 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import { Dispatch } from 'redux'; +import { oc } from 'ts-optchain'; + +import { Action, actions } from '../redux/actions'; +import { AffiliateInfo, ERC20Asset } from '../types'; +import { assetUtils } from '../util/asset'; +import { errorFlasher } from '../util/error_flasher'; + +export const buyQuoteUpdater = { + updateBuyQuoteAsync: async ( + assetBuyer: AssetBuyer, + dispatch: Dispatch<Action>, + asset: ERC20Asset, + assetUnitAmount: BigNumber, + options: { setPending: boolean; dispatchErrors: boolean; affiliateInfo?: AffiliateInfo }, + ): Promise<void> => { + // get a new buy quote. + const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetUnitAmount, asset.metaData.decimals); + if (options.setPending) { + // mark quote as pending + dispatch(actions.setQuoteRequestStatePending()); + } + const feePercentage = oc(options.affiliateInfo).feePercentage(); + let newBuyQuote: BuyQuote | undefined; + try { + newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue, { feePercentage }); + } catch (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; + } + } + // 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)); + }, +}; diff --git a/packages/instant/src/util/coinbase_api.ts b/packages/instant/src/util/coinbase_api.ts new file mode 100644 index 000000000..faac8d82d --- /dev/null +++ b/packages/instant/src/util/coinbase_api.ts @@ -0,0 +1,11 @@ +import { BigNumber, fetchAsync } from '@0x/utils'; + +import { COINBASE_API_BASE_URL } from '../constants'; + +export const coinbaseApi = { + getEthUsdPrice: async (): Promise<BigNumber> => { + const res = await fetchAsync(`${COINBASE_API_BASE_URL}/prices/ETH-USD/buy`); + const resJson = await res.json(); + return new BigNumber(resJson.data.amount); + }, +}; diff --git a/packages/instant/src/util/env.ts b/packages/instant/src/util/env.ts new file mode 100644 index 000000000..4a32f9cb1 --- /dev/null +++ b/packages/instant/src/util/env.ts @@ -0,0 +1,65 @@ +import * as bowser from 'bowser'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { PROVIDER_TYPE_TO_NAME } from '../constants'; +import { Browser, OperatingSystem, ProviderType } from '../types'; + +export const envUtil = { + getBrowser(): Browser { + if (bowser.chrome) { + return Browser.Chrome; + } else if (bowser.firefox) { + return Browser.Firefox; + } else if (bowser.opera) { + return Browser.Opera; + } else if (bowser.msedge) { + return Browser.Edge; + } else if (bowser.safari) { + return Browser.Safari; + } else { + return Browser.Other; + } + }, + isMobileOperatingSystem(): boolean { + return bowser.mobile; + }, + getOperatingSystem(): OperatingSystem { + if (bowser.android) { + return OperatingSystem.Android; + } else if (bowser.ios) { + return OperatingSystem.iOS; + } else if (bowser.mac) { + return OperatingSystem.Mac; + } else if (bowser.windows) { + return OperatingSystem.Windows; + } else if (bowser.windowsphone) { + return OperatingSystem.WindowsPhone; + } else if (bowser.linux) { + return OperatingSystem.Linux; + } else { + return OperatingSystem.Other; + } + }, + getProviderType(provider: Provider): ProviderType | undefined { + if (provider.constructor.name === 'EthereumProvider') { + return ProviderType.Mist; + } else if ((provider as any).isParity) { + return ProviderType.Parity; + } else if ((provider as any).isMetaMask) { + return ProviderType.MetaMask; + } else if (!_.isUndefined(_.get(window, 'SOFA'))) { + return ProviderType.CoinbaseWallet; + } else if (!_.isUndefined(_.get(window, '__CIPHER__'))) { + return ProviderType.Cipher; + } + return; + }, + getProviderName(provider: Provider): string { + const providerTypeIfExists = envUtil.getProviderType(provider); + if (_.isUndefined(providerTypeIfExists)) { + return provider.constructor.name; + } + return PROVIDER_TYPE_TO_NAME[providerTypeIfExists]; + }, +}; diff --git a/packages/instant/src/util/error_flasher.ts b/packages/instant/src/util/error_flasher.ts new file mode 100644 index 000000000..068c12fe2 --- /dev/null +++ b/packages/instant/src/util/error_flasher.ts @@ -0,0 +1,26 @@ +import { Dispatch } from 'redux'; + +import { Action, actions } from '../redux/actions'; + +class ErrorFlasher { + private _timeoutId?: number; + public flashNewErrorMessage(dispatch: Dispatch<Action>, errorMessage?: string, delayMs: number = 7000): void { + this._clearTimeout(); + // dispatch new message + dispatch(actions.setErrorMessage(errorMessage || 'Something went wrong...')); + this._timeoutId = window.setTimeout(() => { + dispatch(actions.hideError()); + }, delayMs); + } + public clearError(dispatch: Dispatch<Action>): void { + this._clearTimeout(); + dispatch(actions.hideError()); + } + private _clearTimeout(): void { + if (this._timeoutId) { + window.clearTimeout(this._timeoutId); + } + } +} + +export const errorFlasher = new ErrorFlasher(); diff --git a/packages/instant/src/util/etherscan.ts b/packages/instant/src/util/etherscan.ts new file mode 100644 index 000000000..f9bf82827 --- /dev/null +++ b/packages/instant/src/util/etherscan.ts @@ -0,0 +1,30 @@ +import * as _ from 'lodash'; + +import { Network } from '../types'; + +const etherscanPrefix = (networkId: number): string | undefined => { + switch (networkId) { + case Network.Kovan: + return 'kovan.'; + case Network.Mainnet: + return ''; + } + return ''; +}; + +export const etherscanUtil = { + getEtherScanTxnAddressIfExists: (txHash: string, networkId: number) => { + const prefix = etherscanPrefix(networkId); + if (_.isUndefined(prefix)) { + return; + } + return `https://${prefix}etherscan.io/tx/${txHash}`; + }, + getEtherScanEthAddressIfExists: (ethAddress: string, networkId: number) => { + const prefix = etherscanPrefix(networkId); + if (_.isUndefined(prefix)) { + return; + } + return `https://${prefix}etherscan.io/address/${ethAddress}`; + }, +}; diff --git a/packages/instant/src/util/format.ts b/packages/instant/src/util/format.ts new file mode 100644 index 000000000..e9c432b2f --- /dev/null +++ b/packages/instant/src/util/format.ts @@ -0,0 +1,56 @@ +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; + +import { ETH_DECIMALS } from '../constants'; + +export const format = { + ethBaseUnitAmount: ( + ethBaseUnitAmount?: BigNumber, + decimalPlaces: number = 4, + defaultText: React.ReactNode = '0 ETH', + ): React.ReactNode => { + if (_.isUndefined(ethBaseUnitAmount)) { + return defaultText; + } + const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseUnitAmount, ETH_DECIMALS); + return format.ethUnitAmount(ethUnitAmount, decimalPlaces); + }, + ethUnitAmount: ( + ethUnitAmount?: BigNumber, + decimalPlaces: number = 4, + defaultText: React.ReactNode = '0 ETH', + ): React.ReactNode => { + if (_.isUndefined(ethUnitAmount)) { + return defaultText; + } + const roundedAmount = ethUnitAmount.round(decimalPlaces).toDigits(decimalPlaces); + return `${roundedAmount} ETH`; + }, + ethBaseUnitAmountInUsd: ( + ethBaseUnitAmount?: BigNumber, + ethUsdPrice?: BigNumber, + decimalPlaces: number = 2, + defaultText: React.ReactNode = '$0.00', + ): React.ReactNode => { + if (_.isUndefined(ethBaseUnitAmount) || _.isUndefined(ethUsdPrice)) { + return defaultText; + } + const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseUnitAmount, ETH_DECIMALS); + return format.ethUnitAmountInUsd(ethUnitAmount, ethUsdPrice, decimalPlaces); + }, + ethUnitAmountInUsd: ( + ethUnitAmount?: BigNumber, + ethUsdPrice?: BigNumber, + decimalPlaces: number = 2, + defaultText: React.ReactNode = '$0.00', + ): React.ReactNode => { + if (_.isUndefined(ethUnitAmount) || _.isUndefined(ethUsdPrice)) { + return defaultText; + } + return `$${ethUnitAmount.mul(ethUsdPrice).toFixed(decimalPlaces)}`; + }, + ethAddress: (address: string): string => { + return `0x${address.slice(2, 7)}…${address.slice(-5)}`; + }, +}; diff --git a/packages/instant/src/util/gas_price_estimator.ts b/packages/instant/src/util/gas_price_estimator.ts new file mode 100644 index 000000000..6b15809a3 --- /dev/null +++ b/packages/instant/src/util/gas_price_estimator.ts @@ -0,0 +1,62 @@ +import { BigNumber, fetchAsync } from '@0x/utils'; + +import { + DEFAULT_ESTIMATED_TRANSACTION_TIME_MS, + DEFAULT_GAS_PRICE, + ETH_GAS_STATION_API_BASE_URL, + GWEI_IN_WEI, +} from '../constants'; + +interface EthGasStationResult { + average: number; + fastestWait: number; + fastWait: number; + fast: number; + safeLowWait: number; + blockNum: number; + avgWait: number; + block_time: number; + speed: number; + fastest: number; + safeLow: number; +} + +interface GasInfo { + gasPriceInWei: BigNumber; + estimatedTimeMs: number; +} + +const fetchFastAmountInWeiAsync = async (): Promise<GasInfo> => { + const res = await fetchAsync(`${ETH_GAS_STATION_API_BASE_URL}/json/ethgasAPI.json`); + const gasInfo = (await res.json()) as EthGasStationResult; + // Eth Gas Station result is gwei * 10 + const gasPriceInGwei = new BigNumber(gasInfo.fast / 10); + // Time is in minutes + const estimatedTimeMs = gasInfo.fastWait * 60 * 1000; // Minutes to MS + return { gasPriceInWei: gasPriceInGwei.mul(GWEI_IN_WEI), estimatedTimeMs }; +}; + +export class GasPriceEstimator { + private _lastFetched?: GasInfo; + public async getGasInfoAsync(): Promise<GasInfo> { + let fetchedAmount: GasInfo | undefined; + try { + fetchedAmount = await fetchFastAmountInWeiAsync(); + } catch { + fetchedAmount = undefined; + } + + if (fetchedAmount) { + this._lastFetched = fetchedAmount; + } + + return ( + fetchedAmount || + this._lastFetched || { + gasPriceInWei: DEFAULT_GAS_PRICE, + estimatedTimeMs: DEFAULT_ESTIMATED_TRANSACTION_TIME_MS, + } + ); + } +} +export const gasPriceEstimator = new GasPriceEstimator(); diff --git a/packages/instant/src/util/heartbeater.ts b/packages/instant/src/util/heartbeater.ts new file mode 100644 index 000000000..e700d489e --- /dev/null +++ b/packages/instant/src/util/heartbeater.ts @@ -0,0 +1,35 @@ +import { intervalUtils } from '@0x/utils'; +import * as _ from 'lodash'; + +type HeartbeatableFunction = () => Promise<void>; +export class Heartbeater { + private _intervalId?: NodeJS.Timer; + private readonly _performImmediatelyOnStart: boolean; + private readonly _performFunction: HeartbeatableFunction; + + public constructor(performingFunctionAsync: HeartbeatableFunction, performImmediatelyOnStart: boolean) { + this._performFunction = performingFunctionAsync; + this._performImmediatelyOnStart = performImmediatelyOnStart; + } + + public start(intervalTimeMs: number): void { + if (!_.isUndefined(this._intervalId)) { + throw new Error('Heartbeat is running, please stop before restarting'); + } + + if (this._performImmediatelyOnStart) { + // tslint:disable-next-line:no-floating-promises + this._performFunction(); + } + + // tslint:disable-next-line:no-unbound-method + this._intervalId = intervalUtils.setAsyncExcludingInterval(this._performFunction, intervalTimeMs, _.noop); + } + + public stop(): void { + if (this._intervalId) { + intervalUtils.clearInterval(this._intervalId); + } + this._intervalId = undefined; + } +} diff --git a/packages/instant/src/util/heartbeater_factory.ts b/packages/instant/src/util/heartbeater_factory.ts new file mode 100644 index 000000000..2b852fb0d --- /dev/null +++ b/packages/instant/src/util/heartbeater_factory.ts @@ -0,0 +1,24 @@ +import { asyncData } from '../redux/async_data'; +import { Store } from '../redux/store'; + +import { Heartbeater } from './heartbeater'; + +export interface HeartbeatFactoryOptions { + store: Store; + shouldPerformImmediatelyOnStart: boolean; +} +export const generateAccountHeartbeater = (options: HeartbeatFactoryOptions): Heartbeater => { + const { store, shouldPerformImmediatelyOnStart } = options; + return new Heartbeater(async () => { + await asyncData.fetchAccountInfoAndDispatchToStore(store.getState().providerState, store.dispatch, false); + }, shouldPerformImmediatelyOnStart); +}; + +export const generateBuyQuoteHeartbeater = (options: HeartbeatFactoryOptions): Heartbeater => { + const { store, shouldPerformImmediatelyOnStart } = options; + return new Heartbeater(async () => { + await asyncData.fetchCurrentBuyQuoteAndDispatchToStore(store.getState(), store.dispatch, { + updateSilently: true, + }); + }, shouldPerformImmediatelyOnStart); +}; diff --git a/packages/instant/src/util/maybe_big_number.ts b/packages/instant/src/util/maybe_big_number.ts new file mode 100644 index 000000000..9d3746e10 --- /dev/null +++ b/packages/instant/src/util/maybe_big_number.ts @@ -0,0 +1,25 @@ +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { Maybe } from '../types'; + +export const maybeBigNumberUtil = { + // converts a string to a Maybe<BigNumber> + // if string is a NaN, considered undefined + stringToMaybeBigNumber: (stringValue: string): Maybe<BigNumber> => { + let validBigNumber: BigNumber; + try { + validBigNumber = new BigNumber(stringValue); + } catch { + return undefined; + } + + return validBigNumber.isNaN() ? undefined : validBigNumber; + }, + areMaybeBigNumbersEqual: (val1: Maybe<BigNumber>, val2: Maybe<BigNumber>): boolean => { + if (!_.isUndefined(val1) && !_.isUndefined(val2)) { + return val1.equals(val2); + } + return _.isUndefined(val1) && _.isUndefined(val2); + }, +}; diff --git a/packages/instant/src/util/provider_factory.ts b/packages/instant/src/util/provider_factory.ts new file mode 100644 index 000000000..603f7674d --- /dev/null +++ b/packages/instant/src/util/provider_factory.ts @@ -0,0 +1,34 @@ +import { EmptyWalletSubprovider, RPCSubprovider, Web3ProviderEngine } from '@0x/subproviders'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { BLOCK_POLLING_INTERVAL_MS, ETHEREUM_NODE_URL_BY_NETWORK } from '../constants'; +import { Maybe, Network } from '../types'; + +export const providerFactory = { + getInjectedProviderIfExists: (): Maybe<Provider> => { + const injectedProviderIfExists = (window as any).ethereum; + if (!_.isUndefined(injectedProviderIfExists)) { + return injectedProviderIfExists; + } + const injectedWeb3IfExists = (window as any).web3; + if (!_.isUndefined(injectedWeb3IfExists) && !_.isUndefined(injectedWeb3IfExists.currentProvider)) { + return injectedWeb3IfExists.currentProvider; + } + return undefined; + }, + getFallbackNoSigningProvider: (network: Network): Provider => { + const providerEngine = new Web3ProviderEngine({ + pollingInterval: BLOCK_POLLING_INTERVAL_MS, + }); + // Intercept calls to `eth_accounts` and always return empty + providerEngine.addProvider(new EmptyWalletSubprovider()); + // Construct an RPC subprovider, all data based requests will be sent via the RPCSubprovider + // TODO(bmillman): make this more resilient to infura failures + const rpcUrl = ETHEREUM_NODE_URL_BY_NETWORK[network]; + providerEngine.addProvider(new RPCSubprovider(rpcUrl)); + // // Start the Provider Engine + providerEngine.start(); + return providerEngine; + }, +}; diff --git a/packages/instant/src/util/provider_state_factory.ts b/packages/instant/src/util/provider_state_factory.ts new file mode 100644 index 000000000..452a71460 --- /dev/null +++ b/packages/instant/src/util/provider_state_factory.ts @@ -0,0 +1,67 @@ +import { Web3Wrapper } from '@0x/web3-wrapper'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { LOADING_ACCOUNT, NO_ACCOUNT } from '../constants'; +import { Maybe, Network, OrderSource, ProviderState } from '../types'; +import { envUtil } from '../util/env'; + +import { assetBuyerFactory } from './asset_buyer_factory'; +import { providerFactory } from './provider_factory'; + +export const providerStateFactory = { + getInitialProviderState: (orderSource: OrderSource, network: Network, provider?: Provider): ProviderState => { + if (!_.isUndefined(provider)) { + return providerStateFactory.getInitialProviderStateFromProvider(orderSource, network, provider); + } + const providerStateFromWindowIfExits = providerStateFactory.getInitialProviderStateFromWindowIfExists( + orderSource, + network, + ); + if (providerStateFromWindowIfExits) { + return providerStateFromWindowIfExits; + } else { + return providerStateFactory.getInitialProviderStateFallback(orderSource, network); + } + }, + getInitialProviderStateFromProvider: ( + orderSource: OrderSource, + network: Network, + provider: Provider, + ): ProviderState => { + const providerState: ProviderState = { + name: envUtil.getProviderName(provider), + provider, + web3Wrapper: new Web3Wrapper(provider), + assetBuyer: assetBuyerFactory.getAssetBuyer(provider, orderSource, network), + account: LOADING_ACCOUNT, + }; + return providerState; + }, + getInitialProviderStateFromWindowIfExists: (orderSource: OrderSource, network: Network): Maybe<ProviderState> => { + const injectedProviderIfExists = providerFactory.getInjectedProviderIfExists(); + if (!_.isUndefined(injectedProviderIfExists)) { + const providerState: ProviderState = { + name: envUtil.getProviderName(injectedProviderIfExists), + provider: injectedProviderIfExists, + web3Wrapper: new Web3Wrapper(injectedProviderIfExists), + assetBuyer: assetBuyerFactory.getAssetBuyer(injectedProviderIfExists, orderSource, network), + account: LOADING_ACCOUNT, + }; + return providerState; + } else { + return undefined; + } + }, + getInitialProviderStateFallback: (orderSource: OrderSource, network: Network): ProviderState => { + const provider = providerFactory.getFallbackNoSigningProvider(network); + const providerState: ProviderState = { + name: envUtil.getProviderName(provider), + provider, + web3Wrapper: new Web3Wrapper(provider), + assetBuyer: assetBuyerFactory.getAssetBuyer(provider, orderSource, network), + account: NO_ACCOUNT, + }; + return providerState; + }, +}; diff --git a/packages/instant/src/util/time.ts b/packages/instant/src/util/time.ts new file mode 100644 index 000000000..bfe69cad5 --- /dev/null +++ b/packages/instant/src/util/time.ts @@ -0,0 +1,39 @@ +const secondsToMinutesAndRemainingSeconds = (seconds: number): { minutes: number; remainingSeconds: number } => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + + return { + minutes, + remainingSeconds, + }; +}; + +const padZero = (aNumber: number): string => { + return aNumber < 10 ? `0${aNumber}` : aNumber.toString(); +}; + +export const timeUtil = { + // converts seconds to human readable version of seconds or minutes + secondsToHumanDescription: (seconds: number): string => { + const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds); + + if (minutes === 0) { + const suffix = seconds > 1 ? 's' : ''; + return `${seconds} second${suffix}`; + } + + const minuteSuffix = minutes > 1 ? 's' : ''; + const minuteText = `${minutes} minute${minuteSuffix}`; + + const secondsSuffix = remainingSeconds > 1 ? 's' : ''; + const secondsText = remainingSeconds === 0 ? '' : ` ${remainingSeconds} second${secondsSuffix}`; + + return `${minuteText}${secondsText}`; + }, + // converts seconds to stopwatch time (i.e. 05:30 and 00:30) + // only goes up to minutes, not hours + secondsToStopwatchTime: (seconds: number): string => { + const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds); + return `${padZero(minutes)}:${padZero(remainingSeconds)}`; + }, +}; diff --git a/packages/instant/src/util/util.ts b/packages/instant/src/util/util.ts new file mode 100644 index 000000000..29b6b1d2b --- /dev/null +++ b/packages/instant/src/util/util.ts @@ -0,0 +1,6 @@ +import * as _ from 'lodash'; + +export const util = { + boundNoop: _.noop.bind(_), + createOpenUrlInNewWindow: (href: string) => () => window.open(href, '_blank'), +}; diff --git a/packages/instant/test/components/zero_ex_instant.test.tsx b/packages/instant/test/components/zero_ex_instant.test.tsx index 5858732cf..e373bb002 100644 --- a/packages/instant/test/components/zero_ex_instant.test.tsx +++ b/packages/instant/test/components/zero_ex_instant.test.tsx @@ -4,10 +4,12 @@ import * as React from 'react'; configure({ adapter: new Adapter() }); -import { ZeroExInstant } from '../../src'; - -describe('<ZeroExInstant />', () => { - it('shallow renders without crashing', () => { - shallow(<ZeroExInstant />); +// TODO: Write non-trivial tests. +// At time of writing we cannot render ZeroExInstant +// because we are looking for a provider on window. +// But in the future it will be dependency injected. +describe('<Test />', () => { + it('runs a test', () => { + shallow(<div />); }); }); diff --git a/packages/instant/test/util/asset.test.ts b/packages/instant/test/util/asset.test.ts new file mode 100644 index 000000000..4229b24ed --- /dev/null +++ b/packages/instant/test/util/asset.test.ts @@ -0,0 +1,48 @@ +import { AssetProxyId, ObjectMap } from '@0x/types'; + +import { Asset, AssetMetaData, ERC20AssetMetaData, Network, ZeroExInstantError } from '../../src/types'; +import { assetUtils } from '../../src/util/asset'; + +const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; +const ZRX_ASSET_DATA_KOVAN = '0xf47261b00000000000000000000000002002d3812f58e35f0ea1ffbf80a75a38c32175fa'; +const ZRX_META_DATA: ERC20AssetMetaData = { + assetProxyId: AssetProxyId.ERC20, + symbol: 'zrx', + decimals: 18, + name: '0x', +}; +const ZRX_ASSET: Asset = { + assetData: ZRX_ASSET_DATA, + metaData: ZRX_META_DATA, +}; +const META_DATA_MAP: ObjectMap<AssetMetaData> = { + [ZRX_ASSET_DATA]: ZRX_META_DATA, +}; + +describe('assetDataUtil', () => { + describe('bestNameForAsset', () => { + it('should return default string if assetData is undefined', () => { + expect(assetUtils.bestNameForAsset(undefined, 'xyz')).toEqual('xyz'); + }); + it('should return ZRX for ZRX assetData', () => { + expect(assetUtils.bestNameForAsset(ZRX_ASSET, 'mah default')).toEqual('ZRX'); + }); + }); + describe('getMetaDataOrThrow', () => { + it('should return the metaData for the supplied mainnet asset data', () => { + expect(assetUtils.getMetaDataOrThrow(ZRX_ASSET_DATA, META_DATA_MAP, Network.Mainnet)).toEqual( + ZRX_META_DATA, + ); + }); + it('should return the metaData for the supplied non-mainnet asset data', () => { + expect(assetUtils.getMetaDataOrThrow(ZRX_ASSET_DATA_KOVAN, META_DATA_MAP, Network.Kovan)).toEqual( + ZRX_META_DATA, + ); + }); + it('should throw if the metaData for the asset is not available', () => { + expect(() => + assetUtils.getMetaDataOrThrow('asset data we dont have', META_DATA_MAP, Network.Mainnet), + ).toThrowError(ZeroExInstantError.AssetMetaDataNotAvailable); + }); + }); +}); diff --git a/packages/instant/test/util/format.test.ts b/packages/instant/test/util/format.test.ts new file mode 100644 index 000000000..fe0a63e6e --- /dev/null +++ b/packages/instant/test/util/format.test.ts @@ -0,0 +1,99 @@ +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; + +import { ETH_DECIMALS } from '../../src/constants'; +import { format } from '../../src/util/format'; + +const BIG_NUMBER_ONE = new BigNumber(1); +const BIG_NUMBER_DECIMAL = new BigNumber(0.432414); +const BIG_NUMBER_IRRATIONAL = new BigNumber(5.3014059295032); +const ONE_ETH_IN_BASE_UNITS = Web3Wrapper.toBaseUnitAmount(BIG_NUMBER_ONE, ETH_DECIMALS); +const DECIMAL_ETH_IN_BASE_UNITS = Web3Wrapper.toBaseUnitAmount(BIG_NUMBER_DECIMAL, ETH_DECIMALS); +const IRRATIONAL_ETH_IN_BASE_UNITS = Web3Wrapper.toBaseUnitAmount(BIG_NUMBER_IRRATIONAL, ETH_DECIMALS); +const BIG_NUMBER_FAKE_ETH_USD_PRICE = new BigNumber(2.534); + +describe('format', () => { + describe('ethBaseAmount', () => { + it('converts 1 ETH in base units to the string `1 ETH`', () => { + expect(format.ethBaseUnitAmount(ONE_ETH_IN_BASE_UNITS)).toBe('1 ETH'); + }); + it('converts .432414 ETH in base units to the string `.4324 ETH`', () => { + expect(format.ethBaseUnitAmount(DECIMAL_ETH_IN_BASE_UNITS)).toBe('0.4324 ETH'); + }); + it('converts 5.3014059295032 ETH in base units to the string `5.301 ETH`', () => { + expect(format.ethBaseUnitAmount(IRRATIONAL_ETH_IN_BASE_UNITS)).toBe('5.301 ETH'); + }); + it('returns defaultText param when ethBaseAmount is not defined', () => { + const defaultText = 'defaultText'; + expect(format.ethBaseUnitAmount(undefined, 4, defaultText)).toBe(defaultText); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethBaseUnitAmount(DECIMAL_ETH_IN_BASE_UNITS, 2)).toBe('0.43 ETH'); + }); + }); + describe('ethUnitAmount', () => { + it('converts BigNumber(1) to the string `1 ETH`', () => { + expect(format.ethUnitAmount(BIG_NUMBER_ONE)).toBe('1 ETH'); + }); + it('converts BigNumer(.432414) to the string `.4324 ETH`', () => { + expect(format.ethUnitAmount(BIG_NUMBER_DECIMAL)).toBe('0.4324 ETH'); + }); + it('converts BigNumber(5.3014059295032) to the string `5.301 ETH`', () => { + expect(format.ethUnitAmount(BIG_NUMBER_IRRATIONAL)).toBe('5.301 ETH'); + }); + it('returns defaultText param when ethUnitAmount is not defined', () => { + const defaultText = 'defaultText'; + expect(format.ethUnitAmount(undefined, 4, defaultText)).toBe(defaultText); + expect(format.ethUnitAmount(BIG_NUMBER_ONE, 4, defaultText)).toBe('1 ETH'); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethUnitAmount(BIG_NUMBER_DECIMAL, 2)).toBe('0.43 ETH'); + }); + }); + describe('ethBaseAmountInUsd', () => { + it('correctly formats 1 ETH to usd according to some price', () => { + expect(format.ethBaseUnitAmountInUsd(ONE_ETH_IN_BASE_UNITS, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$2.53'); + }); + it('correctly formats .432414 ETH to usd according to some price', () => { + expect(format.ethBaseUnitAmountInUsd(DECIMAL_ETH_IN_BASE_UNITS, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe( + '$1.10', + ); + }); + it('correctly formats 5.3014059295032 ETH to usd according to some price', () => { + expect(format.ethBaseUnitAmountInUsd(IRRATIONAL_ETH_IN_BASE_UNITS, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe( + '$13.43', + ); + }); + it('returns defaultText param when ethBaseAmountInUsd or ethUsdPrice is not defined', () => { + const defaultText = 'defaultText'; + expect(format.ethBaseUnitAmountInUsd(undefined, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethBaseUnitAmountInUsd(BIG_NUMBER_ONE, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethBaseUnitAmountInUsd(undefined, BIG_NUMBER_ONE, 2, defaultText)).toBe(defaultText); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethBaseUnitAmountInUsd(DECIMAL_ETH_IN_BASE_UNITS, BIG_NUMBER_FAKE_ETH_USD_PRICE, 4)).toBe( + '$1.0957', + ); + }); + }); + describe('ethUnitAmountInUsd', () => { + it('correctly formats 1 ETH to usd according to some price', () => { + expect(format.ethUnitAmountInUsd(BIG_NUMBER_ONE, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$2.53'); + }); + it('correctly formats .432414 ETH to usd according to some price', () => { + expect(format.ethUnitAmountInUsd(BIG_NUMBER_DECIMAL, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$1.10'); + }); + it('correctly formats 5.3014059295032 ETH to usd according to some price', () => { + expect(format.ethUnitAmountInUsd(BIG_NUMBER_IRRATIONAL, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$13.43'); + }); + it('returns defaultText param when ethUnitAmountInUsd or ethUsdPrice is not defined', () => { + const defaultText = 'defaultText'; + expect(format.ethUnitAmountInUsd(undefined, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethUnitAmountInUsd(BIG_NUMBER_ONE, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethUnitAmountInUsd(undefined, BIG_NUMBER_ONE, 2, defaultText)).toBe(defaultText); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethUnitAmountInUsd(BIG_NUMBER_DECIMAL, BIG_NUMBER_FAKE_ETH_USD_PRICE, 4)).toBe('$1.0957'); + }); + }); +}); diff --git a/packages/instant/test/util/time.test.ts b/packages/instant/test/util/time.test.ts new file mode 100644 index 000000000..fcb4e1875 --- /dev/null +++ b/packages/instant/test/util/time.test.ts @@ -0,0 +1,48 @@ +import { timeUtil } from '../../src/util/time'; + +describe('timeUtil', () => { + describe('secondsToHumanDescription', () => { + const numsToResults: { + [aNumber: number]: string; + } = { + 1: '1 second', + 59: '59 seconds', + 60: '1 minute', + 119: '1 minute 59 seconds', + 120: '2 minutes', + 121: '2 minutes 1 second', + 122: '2 minutes 2 seconds', + }; + + const nums = Object.keys(numsToResults); + nums.forEach(aNum => { + const numInt = parseInt(aNum, 10); + it(`should work for ${aNum} seconds`, () => { + const expectedResult = numsToResults[numInt]; + expect(timeUtil.secondsToHumanDescription(numInt)).toEqual(expectedResult); + }); + }); + }); + describe('secondsToStopwatchTime', () => { + const numsToResults: { + [aNumber: number]: string; + } = { + 1: '00:01', + 59: '00:59', + 60: '01:00', + 119: '01:59', + 120: '02:00', + 121: '02:01', + 2701: '45:01', + }; + + const nums = Object.keys(numsToResults); + nums.forEach(aNum => { + const numInt = parseInt(aNum, 10); + it(`should work for ${aNum} seconds`, () => { + const expectedResult = numsToResults[numInt]; + expect(timeUtil.secondsToStopwatchTime(numInt)).toEqual(expectedResult); + }); + }); + }); +}); diff --git a/packages/instant/tslint.json b/packages/instant/tslint.json index ffaefe83a..d43ee8da7 100644 --- a/packages/instant/tslint.json +++ b/packages/instant/tslint.json @@ -1,3 +1,9 @@ { - "extends": ["@0xproject/tslint-config"] + "extends": ["@0x/tslint-config"], + "rules": { + "custom-no-magic-numbers": false, + "semicolon": [true, "always", "ignore-bound-class-methods"], + "max-classes-per-file": false, + "switch-default": false + } } diff --git a/packages/instant/webpack.config.js b/packages/instant/webpack.config.js index 78a33ce90..239950866 100644 --- a/packages/instant/webpack.config.js +++ b/packages/instant/webpack.config.js @@ -1,11 +1,15 @@ const path = require('path'); +const ip = require('ip'); // The common js bundle (not this one) is built using tsc. // The umd bundle (this one) has a different entrypoint. -module.exports = { - entry: './src/index.umd.ts', +const outputPath = process.env.WEBPACK_OUTPUT_PATH || 'umd'; +const config = { + entry: { + instant: './src/index.umd.ts', + }, output: { - filename: '[name].bundle.js', - path: path.resolve(__dirname, 'public'), + filename: '[name].js', + path: path.resolve(__dirname, outputPath), library: 'zeroExInstant', libraryTarget: 'umd', }, @@ -24,5 +28,15 @@ module.exports = { devServer: { contentBase: path.join(__dirname, 'public'), port: 5000, + host: '0.0.0.0', + after: () => { + if (config.devServer.host === '0.0.0.0') { + console.log( + `webpack-dev-server can be accessed externally at: http://${ip.address()}:${config.devServer.port}`, + ); + } + }, }, }; + +module.exports = config; |