diff options
Diffstat (limited to 'packages/instant')
90 files changed, 4725 insertions, 0 deletions
diff --git a/packages/instant/.dogfood.discharge.json b/packages/instant/.dogfood.discharge.json new file mode 100644 index 000000000..9ade97d01 --- /dev/null +++ b/packages/instant/.dogfood.discharge.json @@ -0,0 +1,13 @@ +{ + "domain": "0x-instant-dogfood", + "build_command": "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 new file mode 100644 index 000000000..e1ce60fa2 --- /dev/null +++ b/packages/instant/.gitignore @@ -0,0 +1,2 @@ +public/main.bundle.js +public/main.bundle.js.map
\ 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..1026b9986 --- /dev/null +++ b/packages/instant/.staging.discharge.json @@ -0,0 +1,13 @@ +{ + "domain": "0x-instant-staging", + "build_command": "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/CHANGELOG.json b/packages/instant/CHANGELOG.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/packages/instant/CHANGELOG.json @@ -0,0 +1 @@ +[] diff --git a/packages/instant/CHANGELOG.md b/packages/instant/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/instant/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/packages/instant/README.md b/packages/instant/README.md new file mode 100644 index 000000000..b83a10508 --- /dev/null +++ b/packages/instant/README.md @@ -0,0 +1,119 @@ +## @0x/instant + +## Installation + +```bash +yarn add @0x/instant +``` + +**Import** + +**CommonJS module** + +```typescript +import { ZeroExInstant } from '@0x/instant'; +``` + +or + +```javascript +var ZeroExInstant = require('@0x/instant').ZeroExInstant; +``` + +If your project is in [TypeScript](https://www.typescriptlang.org/), add the following to your `tsconfig.json`: + +```json +"compilerOptions": { + "typeRoots": ["node_modules/@0x/typescript-typings/types", "node_modules/@types"], +} +``` + +**UMD Module** + +The package is also available as a UMD module named `zeroExInstant`. + +```html +<head> + <script type="text/javascript" src="[zeroExInstantUMDPath]" charset="utf-8"></script> +</head> +<body> + <div id="zeroExInstantContainer"></div> + <script> + zeroExInstant.render({ + // Initialization options + }, '#zeroExInstantContainer'); + </script> +</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. + +Please read our [contribution guidelines](../../CONTRIBUTING.md) before getting started. + +### Install dependencies + +If you don't have yarn workspaces enabled (Yarn < v1.0) - enable them: + +```bash +yarn config set workspaces-experimental true +``` + +Then install dependencies + +```bash +yarn install +``` + +### Build + +To build this package and all other monorepo packages that it depends on, run the following from the monorepo root directory: + +```bash +PKG=@0x/instant yarn build +``` + +Or continuously rebuild on change: + +```bash +PKG=@0x/instant yarn watch +``` + +### Clean + +```bash +yarn clean +``` + +### Lint + +```bash +yarn lint +``` + +### Run Tests + +```bash +yarn test +``` diff --git a/packages/instant/jest.config.js b/packages/instant/jest.config.js new file mode 100644 index 000000000..29c365835 --- /dev/null +++ b/packages/instant/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + roots: ['<rootDir>/test'], + coverageDirectory: 'coverage', + transform: { + '.*.tsx?$': 'ts-jest', + }, + testRegex: '(/__test__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/index.tsx'], +}; diff --git a/packages/instant/package.json b/packages/instant/package.json new file mode 100644 index 000000000..0f6f125fa --- /dev/null +++ b/packages/instant/package.json @@ -0,0 +1,100 @@ +{ + "name": "@0x/instant", + "version": "0.0.3", + "engines": { + "node": ">=6.12" + }, + "private": true, + "description": "0x Instant React Component", + "main": "lib/src/index.js", + "types": "lib/src/index.d.ts", + "scripts": { + "build": "yarn build:all", + "build:all": "run-p build:umd:prod build:commonjs", + "build:umd:prod": "webpack --mode production", + "build:commonjs": "tsc -b", + "build:ci": "yarn build", + "watch_without_deps": "tsc -w", + "dev": "webpack-dev-server --mode development", + "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" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/0xProject/0x-monorepo.git" + }, + "author": "Francesco Agosti", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/0xProject/0x-monorepo/issues" + }, + "homepage": "https://github.com/0xProject/0x-monorepo/packages/instant/README.md", + "dependencies": { + "@0x/assert": "^1.0.14", + "@0x/asset-buyer": "^2.1.0", + "@0x/json-schemas": "^2.0.0", + "@0x/order-utils": "^2.0.0", + "@0x/subproviders": "^2.1.0", + "@0x/types": "^1.2.0", + "@0x/typescript-typings": "^3.0.3", + "@0x/utils": "^2.0.3", + "@0x/web3-wrapper": "^3.1.0", + "ethereum-types": "^1.1.1", + "lodash": "^4.17.10", + "polished": "^2.2.0", + "react": "^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": { + "@0x/tslint-config": "^1.0.9", + "@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.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", + "enzyme": "^3.6.0", + "enzyme-adapter-react-16": "^1.5.0", + "jest": "^23.6.0", + "make-promises-safe": "^1.1.0", + "npm-run-all": "^4.1.2", + "nyc": "^11.0.1", + "shx": "^0.2.2", + "ts-jest": "^23.10.3", + "tslint": "5.11.0", + "typedoc": "0.13.0", + "typescript": "3.0.1", + "webpack": "^4.20.2", + "webpack-cli": "^3.1.1", + "webpack-dev-server": "^3.1.9" + }, + "publishConfig": { + "access": "private" + } +} diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html new file mode 100644 index 000000000..033a125a4 --- /dev/null +++ b/packages/instant/public/index.html @@ -0,0 +1,136 @@ +<!DOCTYPE html> +<html> + +<head> + <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> + <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> + 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> + +</html>
\ No newline at end of file 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..4bb21befb --- /dev/null +++ b/packages/instant/src/components/animations/position_animation.tsx @@ -0,0 +1,80 @@ +import { Keyframes } from 'styled-components'; + +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; +} + +export interface PositionAnimationProps extends PositionAnimationSettings { + position: string; +} + +export const PositionAnimation = + styled.div < + PositionAnimationProps > + ` + animation-name: ${props => + css` + ${slideKeyframeGenerator(props.position, props.top, props.bottom, props.left, props.right)}; + `}; + animation-duration: ${props => props.duration || '0.3s'}; + animation-timing-function: ${props => props.timingFunction}; + animation-delay: 0s; + animation-iteration-count: 1; + animation-fill-mode: forwards; + position: ${props => props.position}; + height: 100%; + width: 100%; +`; + +PositionAnimation.defaultProps = { + position: 'relative', +}; 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..66a314c7f --- /dev/null +++ b/packages/instant/src/components/animations/slide_animation.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { PositionAnimation, PositionAnimationSettings } from './position_animation'; + +export type SlideAnimationState = 'slidIn' | 'slidOut' | 'none'; +export interface SlideAnimationProps { + position: string; + animationState: SlideAnimationState; + slideInSettings: PositionAnimationSettings; + slideOutSettings: PositionAnimationSettings; +} + +export const SlideAnimation: React.StatelessComponent<SlideAnimationProps> = props => { + if (props.animationState === 'none') { + return <React.Fragment>{props.children}</React.Fragment>; + } + const propsToUse = props.animationState === 'slidIn' ? props.slideInSettings : props.slideOutSettings; + return ( + <PositionAnimation position={props.position} {...propsToUse}> + {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..515cd18e9 --- /dev/null +++ b/packages/instant/src/components/buy_button.tsx @@ -0,0 +1,102 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +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 { getBestAddress } from '../util/address'; +import { balanceUtil } from '../util/balance'; +import { gasPriceEstimator } from '../util/gas_price_estimator'; +import { util } from '../util/util'; + +import { Button } from './ui/button'; +import { Text } from './ui/text'; + +export interface BuyButtonProps { + buyQuote?: BuyQuote; + assetBuyer: AssetBuyer; + 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 shouldDisableButton = _.isUndefined(this.props.buyQuote); + return ( + <Button width="100%" onClick={this._handleClick} isDisabled={shouldDisableButton}> + <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px"> + Buy + </Text> + </Button> + ); + } + private readonly _handleClick = async () => { + // The button is disabled when there is no buy quote anyway. + const { buyQuote, assetBuyer, affiliateInfo } = this.props; + if (_.isUndefined(buyQuote)) { + return; + } + + this.props.onValidationPending(buyQuote); + + // TODO(bmillman): move address and balance fetching to the async state + const web3Wrapper = new Web3Wrapper(assetBuyer.provider); + const takerAddress = await getBestAddress(web3Wrapper); + + const hasSufficientEth = await balanceUtil.hasSufficientEth(takerAddress, buyQuote, web3Wrapper); + 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, + 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..bc7319423 --- /dev/null +++ b/packages/instant/src/components/buy_order_progress.tsx @@ -0,0 +1,35 @@ +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..3d0ede7b1 --- /dev/null +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -0,0 +1,66 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +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'; +import { Text } from './ui/text'; + +export interface BuyOrderStateButtonProps { + buyQuote?: BuyQuote; + buyOrderProcessingState: OrderProcessState; + assetBuyer: AssetBuyer; + 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}> + <Text fontColor={ColorOption.white} fontWeight={600} fontSize="16px"> + Back + </Text> + </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 + buyQuote={props.buyQuote} + assetBuyer={props.assetBuyer} + 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/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx new file mode 100644 index 000000000..f21c21b87 --- /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} 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..76d5c66ff --- /dev/null +++ b/packages/instant/src/components/erc20_token_selector.tsx @@ -0,0 +1,111 @@ +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> + <SearchInput + placeholder="Search tokens..." + width="100%" + value={this.state.searchQuery} + onChange={this._handleSearchInputChange} + /> + <Container overflow="scroll" height={{ default: '275px', sm: '75vh' }} 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 stringToSearch = `${token.metaData.name} ${token.metaData.symbol}`; + return _.includes(stringToSearch.toLowerCase(), searchQuery.toLowerCase()); + }; +} + +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} fillColor={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/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx new file mode 100644 index 000000000..b07776b2c --- /dev/null +++ b/packages/instant/src/components/instant_heading.tsx @@ -0,0 +1,137 @@ +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 { + selectedAssetAmount?: BigNumber; + totalEthBaseAmount?: 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%" + borderRadius="3px 3px 0px 0px" + > + <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.selectedAssetAmount)) { + return <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />; + } + return amountFunction(); + } + + private readonly _renderEthAmount = (): React.ReactNode => { + return ( + <Text fontSize="16px" fontColor={ColorOption.white} fontWeight={500}> + {format.ethBaseAmount( + this.props.totalEthBaseAmount, + 4, + <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, + )} + </Text> + ); + }; + + private readonly _renderDollarAmount = (): React.ReactNode => { + return ( + <Text fontSize="16px" fontColor={ColorOption.white}> + {format.ethBaseAmountInUsd( + this.props.totalEthBaseAmount, + this.props.ethUsdPrice, + 2, + <AmountPlaceholder isPulsating={false} color={ColorOption.white} />, + )} + </Text> + ); + }; +} diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx new file mode 100644 index 000000000..9abd7137e --- /dev/null +++ b/packages/instant/src/components/order_details.tsx @@ -0,0 +1,118 @@ +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 { 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; + ethUsdPrice?: BigNumber; + isLoading: boolean; +} +export class OrderDetails extends React.Component<OrderDetailsProps> { + public render(): React.ReactNode { + const { buyQuoteInfo, ethUsdPrice } = this.props; + const buyQuoteAccessor = oc(buyQuoteInfo); + const ethAssetPrice = buyQuoteAccessor.ethPerAssetPrice(); + const ethTokenFee = buyQuoteAccessor.feeEthAmount(); + const totalEthAmount = buyQuoteAccessor.totalEthAmount(); + 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={ethAssetPrice} + ethUsdPrice={ethUsdPrice} + isEthAmountInBaseUnits={false} + isLoading={this.props.isLoading} + /> + <EthAmountRow + rowLabel="Fee" + ethAmount={ethTokenFee} + ethUsdPrice={ethUsdPrice} + isLoading={this.props.isLoading} + /> + <EthAmountRow + rowLabel="Total Cost" + ethAmount={totalEthAmount} + 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.ethBaseAmount : 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.ethBaseAmountInUsd : 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/placing_order_button.tsx b/packages/instant/src/components/placing_order_button.tsx new file mode 100644 index 000000000..4232e6c22 --- /dev/null +++ b/packages/instant/src/components/placing_order_button.tsx @@ -0,0 +1,19 @@ +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'; +import { Text } from './ui/text'; + +export const PlacingOrderButton: React.StatelessComponent<{}> = props => ( + <Button isDisabled={true} width="100%"> + <Container display="inline-block" position="relative" top="3px" marginRight="8px"> + <Spinner widthPx={20} heightPx={20} /> + </Container> + <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px"> + Placing Order… + </Text> + </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..1abadb78b --- /dev/null +++ b/packages/instant/src/components/scaling_input.tsx @@ -0,0 +1,173 @@ +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`; + default: + return '1ch'; + } + }; + 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..583058b5b --- /dev/null +++ b/packages/instant/src/components/secondary_button.tsx @@ -0,0 +1,29 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Button, ButtonProps } from './ui/button'; +import { Text } from './ui/text'; + +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} + > + <Text fontColor={ColorOption.primaryColor} fontWeight={600} fontSize="16px"> + {props.children} + </Text> + </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..a923b9932 --- /dev/null +++ b/packages/instant/src/components/sliding_error.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { PositionAnimationSettings } from './animations/position_animation'; +import { SlideAnimation, SlideAnimationState } 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" + 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 slideUpSettings: PositionAnimationSettings = { + timingFunction: 'ease-in', + top: { + from: slideAmount, + to: '0px', + }, + }; + const slideDownSettings: PositionAnimationSettings = { + timingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1)', + top: { + from: '0px', + to: slideAmount, + }, + }; + return ( + <SlideAnimation + position="relative" + slideInSettings={slideUpSettings} + slideOutSettings={slideDownSettings} + 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..7ef28e09c --- /dev/null +++ b/packages/instant/src/components/sliding_panel.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; +import { zIndex } from '../style/z_index'; + +import { PositionAnimationSettings } from './animations/position_animation'; +import { SlideAnimation, SlideAnimationState } from './animations/slide_animation'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +export interface PanelProps { + title?: string; + onClose?: () => void; +} + +export const Panel: React.StatelessComponent<PanelProps> = ({ title, children, onClose }) => ( + <Container backgroundColor={ColorOption.white} width="100%" height="100%" zIndex={zIndex.panel} padding="20px"> + <Flex justify="space-between"> + {title && ( + <Container marginTop="3px"> + <Text fontColor={ColorOption.darkGrey} fontSize="18px" fontWeight="600" lineHeight="22px"> + {title} + </Text> + </Container> + )} + <Container position="relative" bottom="7px"> + <Icon width={12} color={ColorOption.lightGrey} icon="closeX" onClick={onClose} /> + </Container> + </Flex> + <Container marginTop="10px">{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', + }, + }; + const slideDownSettings: PositionAnimationSettings = { + duration: '0.3s', + timingFunction: 'ease-out', + top: { + from: '0px', + to: slideAmount, + }, + }; + return ( + <SlideAnimation + position="absolute" + slideInSettings={slideUpSettings} + slideOutSettings={slideDownSettings} + animationState={animationState} + > + <Panel {...rest} /> + </SlideAnimation> + ); +}; 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..f2a6f5745 --- /dev/null +++ b/packages/instant/src/components/timed_progress_bar.tsx @@ -0,0 +1,78 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { PROGRESS_FINISH_ANIMATION_TIME_MS, PROGRESS_STALL_AT_WIDTH } from '../constants'; +import { ColorOption, 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 timedProgressProps = this._calculateTimedProgressProps(); + return ( + <Container width="100%" backgroundColor={ColorOption.lightGrey} borderRadius="6px"> + <TimedProgress {...timedProgressProps} ref={this._barRef as any} /> + </Container> + ); + } + + private _calculateTimedProgressProps(): TimedProgressProps { + 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}; + } + `; +}; + +interface TimedProgressProps { + timeMs: number; + fromWidth: string; + toWidth: string; +} + +export const TimedProgress = + styled.div < + TimedProgressProps > + ` + background-color: ${props => props.theme[ColorOption.primaryColor]}; + border-radius: 6px; + height: 6px; + animation: ${props => expandingWidthKeyframes(props.fromWidth, props.toWidth)} + ${props => props.timeMs}ms linear 1 forwards; + `; diff --git a/packages/instant/src/components/ui/button.tsx b/packages/instant/src/components/ui/button.tsx new file mode 100644 index 000000000..5274d835b --- /dev/null +++ b/packages/instant/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { darken, saturate } from 'polished'; +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; + +export interface ButtonProps { + backgroundColor?: ColorOption; + borderColor?: ColorOption; + width?: string; + padding?: string; + type?: string; + isDisabled?: boolean; + onClick?: (event: React.MouseEvent<HTMLElement>) => void; + className?: string; +} + +const PlainButton: React.StatelessComponent<ButtonProps> = ({ children, isDisabled, onClick, type, className }) => ( + <button type={type} className={className} onClick={isDisabled ? undefined : onClick} disabled={isDisabled}> + {children} + </button> +); + +const darkenOnHoverAmount = 0.1; +const darkenOnActiveAmount = 0.2; +const saturateOnFocusAmount = 0.2; +export const Button = styled(PlainButton)` + cursor: ${props => (props.isDisabled ? 'default' : 'pointer')}; + transition: background-color, opacity 0.5s ease; + padding: ${props => props.padding}; + border-radius: 3px; + 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, + borderColor: ColorOption.primaryColor, + width: 'auto', + isDisabled: false, + padding: '1em 2.2em', +}; + +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..eec2777d2 --- /dev/null +++ b/packages/instant/src/components/ui/circle.tsx @@ -0,0 +1,22 @@ +import { styled } from '../../style/theme'; + +export interface CircleProps { + diameter: number; + fillColor?: string; +} + +export const Circle = + styled.div < + CircleProps > + ` + width: ${props => props.diameter}px; + height: ${props => props.diameter}px; + background-color: ${props => props.fillColor}; + border-radius: 50%; +`; + +Circle.displayName = 'Circle'; + +Circle.defaultProps = { + fillColor: '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..403751210 --- /dev/null +++ b/packages/instant/src/components/ui/container.tsx @@ -0,0 +1,87 @@ +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?: string; + 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; + 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-radius')} + ${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('display', props.display)} + ${props => stylesForMedia('width', props.width || 'auto')} + ${props => stylesForMedia('height', props.height || 'auto')} + 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' + }` + : ''}; + } +`; + +Container.defaultProps = { + display: 'block', +}; + +Container.displayName = '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..fd218b0cd --- /dev/null +++ b/packages/instant/src/components/ui/flex.tsx @@ -0,0 +1,39 @@ +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 => stylesForMedia('width', props.width || 'auto')} + ${props => stylesForMedia('height', props.height || 'auto')} +`; + +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..94ea26900 --- /dev/null +++ b/packages/instant/src/components/ui/icon.tsx @@ -0,0 +1,120 @@ +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; + stroke?: string; + 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; +} +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', + stroke: 'white', + 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', + }, +}; + +export interface IconProps { + className?: string; + width: number; + height?: number; + color?: 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]; + 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={iconInfo.stroke} + strokeOpacity={iconInfo.strokeOpacity} + strokeWidth={iconInfo.strokeWidth} + strokeLinecap={iconInfo.strokeLinecap} + strokeLinejoin={iconInfo.strokeLinejoin} + /> + </svg> + </div> + ); +}; + +export const Icon = withTheme(styled(PlainIcon)` + cursor: ${props => (!_.isUndefined(props.onClick) ? 'pointer' : 'default')}; + 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..a884ff7cb --- /dev/null +++ b/packages/instant/src/components/ui/input.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; + +export interface InputProps { + className?: string; + value?: string; + width?: string; + fontSize?: string; + fontColor?: ColorOption; + placeholder?: string; + onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void; +} + +export const Input = + styled.input < + InputProps > + ` + 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..7110ee70f --- /dev/null +++ b/packages/instant/src/components/ui/overlay.tsx @@ -0,0 +1,40 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption, overlayBlack, styled } from '../../style/theme'; + +import { Container } from './container'; +import { Flex } from './flex'; +import { Icon } from './icon'; + +export interface OverlayProps { + className?: string; + onClose?: () => void; + zIndex?: number; +} + +const PlainOverlay: React.StatelessComponent<OverlayProps> = ({ children, className, onClose }) => ( + <Flex height="100vh" className={className}> + <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%' }}> + {children} + </Container> + </Flex> +); +export const Overlay = styled(PlainOverlay)` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: ${props => props.zIndex} + background-color: ${overlayBlack}; +`; + +Overlay.defaultProps = { + zIndex: 100, +}; + +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..cba6e7b20 --- /dev/null +++ b/packages/instant/src/components/ui/text.tsx @@ -0,0 +1,74 @@ +import { darken } from 'polished'; +import * as React from 'react'; + +import { ColorOption, styled } from '../../style/theme'; + +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; +} + +const darkenOnHoverAmount = 0.3; +export const Text = + styled.div < + TextProps > + ` + font-family: ${props => props.fontFamily}; + 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'; + +export const Title: React.StatelessComponent<TextProps> = props => <Text {...props} />; + +Title.defaultProps = { + fontSize: '20px', + fontWeight: 600, + opacity: 1, + fontColor: ColorOption.primaryColor, +}; + +Title.displayName = 'Title'; diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx new file mode 100644 index 000000000..907c42e7a --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { ZeroExInstantContainer } from './zero_ex_instant_container'; +import { ZeroExInstantProvider, ZeroExInstantProviderProps } from './zero_ex_instant_provider'; + +export type ZeroExInstantProps = ZeroExInstantProviderProps; + +export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = props => { + return ( + <ZeroExInstantProvider {...props}> + <ZeroExInstantContainer /> + </ZeroExInstantProvider> + ); +}; 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..ef6adf384 --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; + +import { AvailableERC20TokenSelector } from '../containers/available_erc20_token_selector'; +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 { SelectedAssetBuyOrderProgress } from '../containers/selected_asset_buy_order_progress'; + +import { ColorOption } from '../style/theme'; +import { zIndex } from '../style/z_index'; + +import { SlideAnimationState } from './animations/slide_animation'; +import { SlidingPanel } from './sliding_panel'; +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; + +export interface ZeroExInstantContainerProps {} +export interface ZeroExInstantContainerState { + tokenSelectionPanelAnimationState: SlideAnimationState; +} + +export class ZeroExInstantContainer extends React.Component<ZeroExInstantContainerProps, ZeroExInstantContainerState> { + public state = { + tokenSelectionPanelAnimationState: 'none' as SlideAnimationState, + }; + public render(): React.ReactNode { + return ( + <Container + width={{ default: '350px', sm: '100%' }} + height={{ default: 'auto', sm: '100%' }} + position="relative" + > + <Container zIndex={zIndex.errorPopup} position="relative"> + <LatestError /> + </Container> + <Container + zIndex={zIndex.mainContainer} + position="relative" + backgroundColor={ColorOption.white} + borderRadius="3px" + hasBoxShadow={true} + overflow="hidden" + height="100%" + > + <Flex direction="column" height="100%" justify="flex-start"> + <SelectedAssetInstantHeading onSelectAssetClick={this._handleSymbolClick} /> + <SelectedAssetBuyOrderProgress /> + <LatestBuyQuoteOrderDetails /> + <Container padding="20px" width="100%"> + <SelectedAssetBuyOrderStateButtons /> + </Container> + </Flex> + <SlidingPanel + title="Select Token" + animationState={this.state.tokenSelectionPanelAnimationState} + onClose={this._handlePanelClose} + > + <AvailableERC20TokenSelector onTokenSelect={this._handlePanelClose} /> + </SlidingPanel> + </Container> + </Container> + ); + } + 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..3461600e1 --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_overlay.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { Overlay } from './ui/overlay'; +import { ZeroExInstantContainer } from './zero_ex_instant_container'; +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 onClose={onClose} zIndex={zIndex}> + <ZeroExInstantContainer /> + </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..1fb5cf64f --- /dev/null +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -0,0 +1,122 @@ +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 { 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 { 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 { 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; + // 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, + ), + selectedAssetAmount: _.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(); + // tslint:disable-next-line:no-floating-promises + asyncData.fetchEthPriceAndDispatchToStore(this._store); + // fetch available assets if none are specified + if (_.isUndefined(state.availableAssets)) { + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAvailableAssetDatasAndDispatchToStore(this._store); + } + + // 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 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..00521b56e --- /dev/null +++ b/packages/instant/src/constants.ts @@ -0,0 +1,23 @@ +import { BigNumber } from '@0x/utils'; + +import { Network } 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_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 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 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 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/latest_buy_quote_order_details.ts b/packages/instant/src/containers/latest_buy_quote_order_details.ts new file mode 100644 index 000000000..2b59ed3ae --- /dev/null +++ b/packages/instant/src/containers/latest_buy_quote_order_details.ts @@ -0,0 +1,30 @@ +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; + ethUsdPrice?: BigNumber; + isLoading: boolean; +} + +const mapStateToProps = (state: State, _ownProps: LatestBuyQuoteOrderDetailsProps): ConnectedState => ({ + // use the worst case quote info + buyQuoteInfo: oc(state).latestBuyQuote.worstCaseQuoteInfo(), + 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..99e55a6c4 --- /dev/null +++ b/packages/instant/src/containers/latest_error.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { connect } from 'react-redux'; + +import { SlideAnimationState } from '../components/animations/slide_animation'; +import { SlidingError } from '../components/sliding_error'; +import { State } from '../redux/reducer'; +import { Asset, DisplayStatus } from '../types'; + +export interface LatestErrorComponentProps { + asset?: Asset; + latestErrorMessage?: string; + animationState: SlideAnimationState; +} + +export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => { + if (!props.latestErrorMessage) { + return <div />; + } + return <SlidingError animationState={props.animationState} icon="😢" message={props.latestErrorMessage} />; +}; + +interface ConnectedState { + asset?: Asset; + latestErrorMessage?: string; + animationState: SlideAnimationState; +} +export interface LatestErrorProps {} +const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({ + asset: state.selectedAsset, + latestErrorMessage: state.latestErrorMessage, + animationState: state.latestErrorDisplayStatus === DisplayStatus.Present ? 'slidIn' : 'slidOut', +}); + +export const LatestError = connect(mapStateToProps)(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..c3a5e88b9 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts @@ -0,0 +1,92 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +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 { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; +import { errorFlasher } from '../util/error_flasher'; +import { etherscanUtil } from '../util/etherscan'; + +interface ConnectedState { + buyQuote?: BuyQuote; + buyOrderProcessingState: OrderProcessState; + assetBuyer: AssetBuyer; + 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; + return { + buyOrderProcessingState: state.buyOrderState.processState, + assetBuyer, + 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..a407279e6 --- /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 { + selectedAssetAmount?: BigNumber; + totalEthBaseAmount?: BigNumber; + ethUsdPrice?: BigNumber; + quoteRequestState: AsyncProcessState; + buyOrderState: OrderState; +} + +const mapStateToProps = (state: State, _ownProps: InstantHeadingProps): ConnectedState => ({ + selectedAssetAmount: state.selectedAssetAmount, + totalEthBaseAmount: 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..784eb4bd0 --- /dev/null +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -0,0 +1,162 @@ +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { oc } from 'ts-optchain'; + +import { ERC20AssetAmountInput } 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, OrderProcessState } from '../types'; +import { assetUtils } from '../util/asset'; +import { errorFlasher } from '../util/error_flasher'; + +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; +} + +interface ConnectedProps { + value?: BigNumber; + asset?: ERC20Asset; + onChange: (value?: BigNumber, asset?: ERC20Asset) => void; + isDisabled: boolean; + numberOfAssetsAvailable?: number; +} + +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.selectedAssetAmount, + asset: selectedAsset, + isDisabled, + numberOfAssetsAvailable, + affiliateInfo: state.affiliateInfo, + }; +}; + +const updateBuyQuoteAsync = async ( + assetBuyer: AssetBuyer, + dispatch: Dispatch<Action>, + asset: ERC20Asset, + assetAmount: BigNumber, + affiliateInfo?: AffiliateInfo, +): Promise<void> => { + // get a new buy quote. + const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals); + + // mark quote as pending + dispatch(actions.setQuoteRequestStatePending()); + + const feePercentage = oc(affiliateInfo).feePercentage(); + let newBuyQuote: BuyQuote | undefined; + try { + newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue, { feePercentage }); + } catch (error) { + 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; + } + return; + } + // We have a successful new buy quote + errorFlasher.clearError(dispatch); + // invalidate the last buy quote. + dispatch(actions.updateLatestBuyQuote(newBuyQuote)); +}; + +const debouncedUpdateBuyQuoteAsync = _.debounce(updateBuyQuoteAsync, 200, { trailing: true }); + +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, 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..970b6c383 --- /dev/null +++ b/packages/instant/src/data/asset_meta_data_map.ts @@ -0,0 +1,85 @@ +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: 'rgb(54, 50, 60)', + symbol: 'zrx', + name: '0x', + }, + '0xf47261b000000000000000000000000042d6622dece394b54999fbd73d108123806f6a18': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#ec3e6c', + symbol: 'spank', + name: 'Spank', + }, + '0xf47261b0000000000000000000000000d26114cd6ee289accf82350c8d8487fedb8a0c07': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#2e61ea', + symbol: 'omg', + name: 'OmiseGo', + }, + '0xf47261b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#87e4ca', + symbol: 'mkr', + name: 'Maker', + }, + '0xf47261b00000000000000000000000000d8775f648430679a709e98d2b0cb6250d2887ef': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#9c326c', + symbol: 'bat', + name: 'Basic Attention Token', + }, + '0xf47261b0000000000000000000000000744d70fdbe2ba4cf95131626614a1763df805b9e': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#5663b0', + symbol: 'snt', + name: 'Status', + }, + '0xf47261b00000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc942': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#f08839', + symbol: 'mana', + name: 'Decentraland', + }, + '0xf47261b0000000000000000000000000a74476443119a942de498590fe1f2454d7d4ac0d': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#263469', + symbol: 'gnt', + name: 'Golem', + }, + '0xf47261b000000000000000000000000012480e24eb5bec1a9d4369cab6a80cad3c0a377a': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#de5445', + symbol: 'sub', + name: 'Substratum', + }, + '0xf47261b000000000000000000000000008d32b0da63e2C3bcF8019c9c5d849d7a9d791e6': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#000', + symbol: 'dentacoin', + name: 'Dentacoin', + }, + '0xf47261b00000000000000000000000001985365e9f78359a9b6ad760e32412f4a445e862': { + assetProxyId: AssetProxyId.ERC20, + decimals: 18, + primaryColor: '#512D80', + symbol: 'rep', + name: 'Augur', + }, +}; diff --git a/packages/instant/src/globals.d.ts b/packages/instant/src/globals.d.ts new file mode 100644 index 000000000..94e63a32d --- /dev/null +++ b/packages/instant/src/globals.d.ts @@ -0,0 +1,6 @@ +declare module '*.json' { + const json: any; + /* tslint:disable */ + export default json; + /* tslint:enable */ +} diff --git a/packages/instant/src/index.ts b/packages/instant/src/index.ts new file mode 100644 index 000000000..6e611dae8 --- /dev/null +++ b/packages/instant/src/index.ts @@ -0,0 +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 new file mode 100644 index 000000000..59d1e646f --- /dev/null +++ b/packages/instant/src/index.umd.ts @@ -0,0 +1,55 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR, INJECTED_DIV_ID } from './constants'; +import { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './index'; +import { assert } from './util/assert'; + +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); + 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..c41c5054b --- /dev/null +++ b/packages/instant/src/redux/actions.ts @@ -0,0 +1,60 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { ActionsUnion, Asset } 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 { + UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE', + UPDATE_SELECTED_ASSET_AMOUNT = 'UPDATE_SELECTED_ASSET_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', +} + +export const actions = { + updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), + updateSelectedAssetAmount: (amount?: BigNumber) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_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), +}; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts new file mode 100644 index 000000000..c3d190af2 --- /dev/null +++ b/packages/instant/src/redux/async_data.ts @@ -0,0 +1,36 @@ +import * as _ from 'lodash'; + +import { BIG_NUMBER_ZERO } from '../constants'; +import { assetUtils } from '../util/asset'; +import { coinbaseApi } from '../util/coinbase_api'; +import { errorFlasher } from '../util/error_flasher'; + +import { actions } from './actions'; +import { Store } from './store'; + +export const asyncData = { + fetchEthPriceAndDispatchToStore: async (store: Store) => { + try { + const ethUsdPrice = await coinbaseApi.getEthUsdPrice(); + store.dispatch(actions.updateEthUsdPrice(ethUsdPrice)); + } catch (e) { + const errorMessage = 'Error fetching ETH/USD price'; + errorFlasher.flashNewErrorMessage(store.dispatch, errorMessage); + store.dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + } + }, + fetchAvailableAssetDatasAndDispatchToStore: async (store: Store) => { + const { providerState, assetMetaDataMap, network } = store.getState(); + const assetBuyer = providerState.assetBuyer; + try { + const assetDatas = await assetBuyer.getAvailableAssetDatasAsync(); + const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, assetMetaDataMap, network); + store.dispatch(actions.setAvailableAssets(assets)); + } catch (e) { + const errorMessage = 'Could not find any assets'; + errorFlasher.flashNewErrorMessage(store.dispatch, errorMessage); + // On error, just specify that none are available + store.dispatch(actions.setAvailableAssets([])); + } + }, +}; diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts new file mode 100644 index 000000000..4a939839a --- /dev/null +++ b/packages/instant/src/redux/reducer.ts @@ -0,0 +1,218 @@ +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 { assetMetaDataMap } from '../data/asset_meta_data_map'; +import { + AffiliateInfo, + Asset, + AssetMetaData, + AsyncProcessState, + DisplayStatus, + Network, + OrderProcessState, + OrderState, + ProviderState, +} 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; +} + +// 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[]; + selectedAssetAmount: 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, +}; + +export const createReducer = (initialState: State) => { + const reducer = (state: State = initialState, action: Action): State => { + switch (action.type) { + case ActionTypes.UPDATE_ETH_USD_PRICE: + return { + ...state, + ethUsdPrice: action.data, + }; + case ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT: + return { + ...state, + selectedAssetAmount: 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 }, + selectedAssetAmount: undefined, + }; + case ActionTypes.SET_AVAILABLE_ASSETS: + return { + ...state, + availableAssets: action.data, + }; + default: + return state; + } + }; + return reducer; +}; + +const doesBuyQuoteMatchState = (buyQuote: BuyQuote, state: State): boolean => { + const selectedAssetIfExists = state.selectedAsset; + const selectedAssetAmountIfExists = state.selectedAssetAmount; + // if no selectedAsset or selectedAssetAmount exists on the current state, return false + if (_.isUndefined(selectedAssetIfExists) || _.isUndefined(selectedAssetAmountIfExists)) { + 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( + selectedAssetAmountIfExists, + 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..beabbac46 --- /dev/null +++ b/packages/instant/src/style/media.ts @@ -0,0 +1,43 @@ +import { InterpolationValue } from 'styled-components'; + +import { css } from './theme'; + +export enum ScreenWidths { + Sm = 40, + Md = 52, + Lg = 64, +} + +const generateMediaWrapper = (screenWidth: ScreenWidths) => (...args: any[]) => css` + @media (max-width: ${screenWidth}em) { + ${css.apply(css, args)}; + } +`; + +const media = { + small: generateMediaWrapper(ScreenWidths.Sm), + medium: generateMediaWrapper(ScreenWidths.Md), + large: generateMediaWrapper(ScreenWidths.Lg), +}; + +export interface ScreenSpecifications { + default: string; + sm?: string; + md?: string; + lg?: string; +} +export type MediaChoice = string | ScreenSpecifications; +export const stylesForMedia = (cssPropertyName: string, choice: MediaChoice): InterpolationValue[] => { + if (typeof choice === 'string') { + return css` + ${cssPropertyName}: ${choice}; + `; + } + + 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}`} + `; +}; diff --git a/packages/instant/src/style/theme.ts b/packages/instant/src/style/theme.ts new file mode 100644 index 000000000..d10c9b72c --- /dev/null +++ b/packages/instant/src/style/theme.ts @@ -0,0 +1,36 @@ +import * as styledComponents from 'styled-components'; + +const { default: styled, css, keyframes, withTheme, 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', +} + +export const theme: Theme = { + primaryColor: '#333', + black: 'black', + lightGrey: '#999999', + grey: '#666666', + feintGrey: '#DEDEDE', + lightestGrey: '#EEEEEE', + darkGrey: '#333333', + white: 'white', + lightOrange: '#F9F2ED', + darkOrange: '#F2994C', +}; + +export const transparentWhite = 'rgba(255,255,255,0.3)'; +export const overlayBlack = 'rgba(0, 0, 0, 0.6)'; + +export { styled, css, keyframes, withTheme, 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..727a5189d --- /dev/null +++ b/packages/instant/src/style/z_index.ts @@ -0,0 +1,5 @@ +export const zIndex = { + errorPopup: 1, + mainContainer: 2, + panel: 3, +}; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts new file mode 100644 index 000000000..d65f70008 --- /dev/null +++ b/packages/instant/src/types.ts @@ -0,0 +1,122 @@ +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 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 { + provider: Provider; + assetBuyer: AssetBuyer; + web3Wrapper: Web3Wrapper; + account: Account; +} + +export enum AccountState { + Loading = 'LOADING', + Ready = 'READY', + Locked = 'LOCKED', // TODO(bmillman): break this up into locked / privacy mode enabled + Error = 'ERROR', + None = 'NONE,', +} + +export interface AccountReady { + state: AccountState.Ready; + address: string; + ethBalanceInWei?: BigNumber; +} +export interface AccountNotReady { + state: AccountState.None | AccountState.Loading | AccountState.Locked | AccountState.Error; +} + +export type Account = AccountReady | AccountNotReady; + +export type OrderSource = string | SignedOrder[]; 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..fbfbb19f3 --- /dev/null +++ b/packages/instant/src/util/asset.ts @@ -0,0 +1,111 @@ +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; + default: + return defaultName; + } + }, + 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/balance.ts b/packages/instant/src/util/balance.ts new file mode 100644 index 000000000..f2271495b --- /dev/null +++ b/packages/instant/src/util/balance.ts @@ -0,0 +1,13 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; + +export const balanceUtil = { + hasSufficientEth: async (takerAddress: string | undefined, buyQuote: BuyQuote, web3Wrapper: Web3Wrapper) => { + if (_.isUndefined(takerAddress)) { + return false; + } + const balanceWei = await web3Wrapper.getBalanceInWeiAsync(takerAddress); + return balanceWei.gte(buyQuote.worstCaseQuoteInfo.totalEthAmount); + }, +}; 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/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..cfc2578a3 --- /dev/null +++ b/packages/instant/src/util/etherscan.ts @@ -0,0 +1,24 @@ +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 ''; + default: + return undefined; + } +}; + +export const etherscanUtil = { + getEtherScanTxnAddressIfExists: (txHash: string, networkId: number) => { + const prefix = etherscanPrefix(networkId); + if (_.isUndefined(prefix)) { + return; + } + return `https://${prefix}etherscan.io/tx/${txHash}`; + }, +}; diff --git a/packages/instant/src/util/format.ts b/packages/instant/src/util/format.ts new file mode 100644 index 000000000..4a48dec9d --- /dev/null +++ b/packages/instant/src/util/format.ts @@ -0,0 +1,53 @@ +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; + +import { ETH_DECIMALS } from '../constants'; + +export const format = { + ethBaseAmount: ( + ethBaseAmount?: BigNumber, + decimalPlaces: number = 4, + defaultText: React.ReactNode = '0 ETH', + ): React.ReactNode => { + if (_.isUndefined(ethBaseAmount)) { + return defaultText; + } + const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, 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`; + }, + ethBaseAmountInUsd: ( + ethBaseAmount?: BigNumber, + ethUsdPrice?: BigNumber, + decimalPlaces: number = 2, + defaultText: React.ReactNode = '$0.00', + ): React.ReactNode => { + if (_.isUndefined(ethBaseAmount) || _.isUndefined(ethUsdPrice)) { + return defaultText; + } + const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, 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)}`; + }, +}; 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/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..18b188d89 --- /dev/null +++ b/packages/instant/src/util/provider_state_factory.ts @@ -0,0 +1,69 @@ +import { Web3Wrapper } from '@0x/web3-wrapper'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { AccountNotReady, AccountState, Maybe, Network, OrderSource, ProviderState } from '../types'; + +import { assetBuyerFactory } from './asset_buyer_factory'; +import { providerFactory } from './provider_factory'; + +const LOADING_ACCOUNT: AccountNotReady = { + state: AccountState.Loading, +}; +const NO_ACCOUNT: AccountNotReady = { + state: AccountState.None, +}; + +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 = { + 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 = { + 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 = { + 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..232a86850 --- /dev/null +++ b/packages/instant/src/util/util.ts @@ -0,0 +1,5 @@ +import * as _ from 'lodash'; + +export const util = { + boundNoop: _.noop.bind(_), +}; diff --git a/packages/instant/test/components/zero_ex_instant.test.tsx b/packages/instant/test/components/zero_ex_instant.test.tsx new file mode 100644 index 000000000..e373bb002 --- /dev/null +++ b/packages/instant/test/components/zero_ex_instant.test.tsx @@ -0,0 +1,15 @@ +import { configure, shallow } from 'enzyme'; +import * as Adapter from 'enzyme-adapter-react-16'; +import * as React from 'react'; + +configure({ adapter: new Adapter() }); + +// 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..c346b7604 --- /dev/null +++ b/packages/instant/test/util/format.test.ts @@ -0,0 +1,97 @@ +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.ethBaseAmount(ONE_ETH_IN_BASE_UNITS)).toBe('1 ETH'); + }); + it('converts .432414 ETH in base units to the string `.4324 ETH`', () => { + expect(format.ethBaseAmount(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.ethBaseAmount(IRRATIONAL_ETH_IN_BASE_UNITS)).toBe('5.301 ETH'); + }); + it('returns defaultText param when ethBaseAmount is not defined', () => { + const defaultText = 'defaultText'; + expect(format.ethBaseAmount(undefined, 4, defaultText)).toBe(defaultText); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethBaseAmount(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.ethBaseAmountInUsd(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.ethBaseAmountInUsd(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.ethBaseAmountInUsd(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.ethBaseAmountInUsd(undefined, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethBaseAmountInUsd(BIG_NUMBER_ONE, undefined, 2, defaultText)).toBe(defaultText); + expect(format.ethBaseAmountInUsd(undefined, BIG_NUMBER_ONE, 2, defaultText)).toBe(defaultText); + }); + it('it allows for configurable decimal places', () => { + expect(format.ethBaseAmountInUsd(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/tsconfig.json b/packages/instant/tsconfig.json new file mode 100644 index 000000000..28a6190b8 --- /dev/null +++ b/packages/instant/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": ".", + "jsx": "react", + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "module": "ESNext", + "moduleResolution": "node", + "lib": ["es2015", "dom"], + "target": "es5", + "sourceMap": true + }, + "include": ["./src/**/*", "./test/**/*"], + "exclude": ["./src/index.umd.ts"] +} diff --git a/packages/instant/tslint.json b/packages/instant/tslint.json new file mode 100644 index 000000000..08b76be97 --- /dev/null +++ b/packages/instant/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": ["@0x/tslint-config"], + "rules": { + "custom-no-magic-numbers": false, + "semicolon": [true, "always", "ignore-bound-class-methods"], + "max-classes-per-file": false + } +} diff --git a/packages/instant/typedoc-tsconfig.json b/packages/instant/typedoc-tsconfig.json new file mode 100644 index 000000000..c9b0af1ae --- /dev/null +++ b/packages/instant/typedoc-tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../typedoc-tsconfig", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["./src/**/*", "./test/**/*"] +} diff --git a/packages/instant/webpack.config.js b/packages/instant/webpack.config.js new file mode 100644 index 000000000..78a33ce90 --- /dev/null +++ b/packages/instant/webpack.config.js @@ -0,0 +1,28 @@ +const path = require('path'); +// 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', + output: { + filename: '[name].bundle.js', + path: path.resolve(__dirname, 'public'), + library: 'zeroExInstant', + libraryTarget: 'umd', + }, + devtool: 'source-map', + resolve: { + extensions: ['.js', '.json', '.ts', '.tsx'], + }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + loader: 'awesome-typescript-loader', + }, + ], + }, + devServer: { + contentBase: path.join(__dirname, 'public'), + port: 5000, + }, +}; |