aboutsummaryrefslogtreecommitdiffstats
path: root/packages/instant/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/instant/src')
-rw-r--r--packages/instant/src/components/amount_input.tsx49
-rw-r--r--packages/instant/src/components/amount_placeholder.tsx2
-rw-r--r--packages/instant/src/components/animations/position_animation.tsx110
-rw-r--r--packages/instant/src/components/animations/slide_animation.tsx26
-rw-r--r--packages/instant/src/components/animations/slide_animations.tsx54
-rw-r--r--packages/instant/src/components/asset_amount_input.tsx39
-rw-r--r--packages/instant/src/components/buy_button.tsx74
-rw-r--r--packages/instant/src/components/buy_order_progress.tsx35
-rw-r--r--packages/instant/src/components/buy_order_state_button.tsx26
-rw-r--r--packages/instant/src/components/buy_order_state_buttons.tsx71
-rw-r--r--packages/instant/src/components/css_reset.tsx32
-rw-r--r--packages/instant/src/components/erc20_asset_amount_input.tsx161
-rw-r--r--packages/instant/src/components/erc20_token_selector.tsx111
-rw-r--r--packages/instant/src/components/instant_heading.tsx44
-rw-r--r--packages/instant/src/components/order_details.tsx7
-rw-r--r--packages/instant/src/components/payment_method.tsx45
-rw-r--r--packages/instant/src/components/payment_method_dropdown.tsx44
-rw-r--r--packages/instant/src/components/placing_order_button.tsx7
-rw-r--r--packages/instant/src/components/retry_button.tsx11
-rw-r--r--packages/instant/src/components/scaling_amount_input.tsx83
-rw-r--r--packages/instant/src/components/scaling_input.tsx173
-rw-r--r--packages/instant/src/components/search_input.tsx29
-rw-r--r--packages/instant/src/components/secondary_button.tsx12
-rw-r--r--packages/instant/src/components/sliding_error.tsx77
-rw-r--r--packages/instant/src/components/sliding_panel.tsx77
-rw-r--r--packages/instant/src/components/time_counter.tsx78
-rw-r--r--packages/instant/src/components/timed_progress_bar.tsx80
-rw-r--r--packages/instant/src/components/ui/button.tsx62
-rw-r--r--packages/instant/src/components/ui/circle.tsx27
-rw-r--r--packages/instant/src/components/ui/container.tsx87
-rw-r--r--packages/instant/src/components/ui/dropdown.tsx134
-rw-r--r--packages/instant/src/components/ui/flex.tsx36
-rw-r--r--packages/instant/src/components/ui/icon.tsx105
-rw-r--r--packages/instant/src/components/ui/index.ts5
-rw-r--r--packages/instant/src/components/ui/input.tsx32
-rw-r--r--packages/instant/src/components/ui/overlay.tsx39
-rw-r--r--packages/instant/src/components/ui/text.tsx65
-rw-r--r--packages/instant/src/components/view_transaction_button.tsx11
-rw-r--r--packages/instant/src/components/zero_ex_instant.tsx85
-rw-r--r--packages/instant/src/components/zero_ex_instant_container.tsx91
-rw-r--r--packages/instant/src/components/zero_ex_instant_overlay.tsx40
-rw-r--r--packages/instant/src/components/zero_ex_instant_provider.tsx149
-rw-r--r--packages/instant/src/constants.ts32
-rw-r--r--packages/instant/src/containers/available_erc20_token_selector.ts45
-rw-r--r--packages/instant/src/containers/latest_buy_quote_order_details.ts2
-rw-r--r--packages/instant/src/containers/latest_error.tsx54
-rw-r--r--packages/instant/src/containers/selected_asset_amount_input.ts123
-rw-r--r--packages/instant/src/containers/selected_asset_buy_button.ts55
-rw-r--r--packages/instant/src/containers/selected_asset_buy_order_progress.ts13
-rw-r--r--packages/instant/src/containers/selected_asset_buy_order_state_button.tsx20
-rw-r--r--packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts104
-rw-r--r--packages/instant/src/containers/selected_asset_instant_heading.ts6
-rw-r--r--packages/instant/src/containers/selected_asset_retry_button.tsx26
-rw-r--r--packages/instant/src/containers/selected_asset_view_transaction_button.tsx38
-rw-r--r--packages/instant/src/containers/selected_erc20_asset_amount_input.ts116
-rw-r--r--packages/instant/src/data/asset_data_network_mapping.ts51
-rw-r--r--packages/instant/src/data/asset_meta_data_map.ts71
-rw-r--r--packages/instant/src/index.ts1
-rw-r--r--packages/instant/src/index.umd.ts55
-rw-r--r--packages/instant/src/redux/actions.ts32
-rw-r--r--packages/instant/src/redux/async_data.ts103
-rw-r--r--packages/instant/src/redux/reducer.ts322
-rw-r--r--packages/instant/src/redux/store.ts7
-rw-r--r--packages/instant/src/style/fonts.ts10
-rw-r--r--packages/instant/src/style/media.ts51
-rw-r--r--packages/instant/src/style/theme.ts19
-rw-r--r--packages/instant/src/style/z_index.ts9
-rw-r--r--packages/instant/src/types.ts78
-rw-r--r--packages/instant/src/util/address.ts6
-rw-r--r--packages/instant/src/util/assert.ts55
-rw-r--r--packages/instant/src/util/asset.ts70
-rw-r--r--packages/instant/src/util/asset_buyer_factory.ts17
-rw-r--r--packages/instant/src/util/buy_quote_updater.ts59
-rw-r--r--packages/instant/src/util/coinbase_api.ts7
-rw-r--r--packages/instant/src/util/error.ts68
-rw-r--r--packages/instant/src/util/error_flasher.ts26
-rw-r--r--packages/instant/src/util/etherscan.ts7
-rw-r--r--packages/instant/src/util/format.ts11
-rw-r--r--packages/instant/src/util/gas_price_estimator.ts62
-rw-r--r--packages/instant/src/util/heartbeater.ts35
-rw-r--r--packages/instant/src/util/heartbeater_factory.ts22
-rw-r--r--packages/instant/src/util/maybe_big_number.ts25
-rw-r--r--packages/instant/src/util/provider.ts12
-rw-r--r--packages/instant/src/util/provider_factory.ts34
-rw-r--r--packages/instant/src/util/provider_state_factory.ts63
-rw-r--r--packages/instant/src/util/time.ts39
-rw-r--r--packages/instant/src/util/web3_wrapper.ts5
87 files changed, 3571 insertions, 1020 deletions
diff --git a/packages/instant/src/components/amount_input.tsx b/packages/instant/src/components/amount_input.tsx
deleted file mode 100644
index c89fb05ad..000000000
--- a/packages/instant/src/components/amount_input.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { BigNumber } from '@0x/utils';
-import * as _ from 'lodash';
-import * as React from 'react';
-
-import { ColorOption } from '../style/theme';
-import { util } from '../util/util';
-
-import { Container, Input } from './ui';
-
-export interface AmountInputProps {
- fontColor?: ColorOption;
- fontSize?: string;
- value?: BigNumber;
- onChange: (value?: BigNumber) => void;
-}
-
-export class AmountInput extends React.Component<AmountInputProps> {
- public static defaultProps = {
- onChange: util.boundNoop,
- };
- public render(): React.ReactNode {
- const { fontColor, fontSize, value } = this.props;
- return (
- <Container borderBottom="1px solid rgba(255,255,255,0.3)" display="inline-block">
- <Input
- fontColor={fontColor}
- fontSize={fontSize}
- onChange={this._handleChange}
- value={!_.isUndefined(value) ? value.toString() : ''}
- placeholder="0.00"
- width="2.2em"
- />
- </Container>
- );
- }
- private readonly _handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
- const value = event.target.value;
- let bigNumberValue;
- if (!_.isEmpty(value)) {
- try {
- bigNumberValue = new BigNumber(event.target.value);
- } catch {
- // We don't want to allow values that can't be a BigNumber, so don't even call onChange.
- return;
- }
- }
- this.props.onChange(bigNumberValue);
- };
-}
diff --git a/packages/instant/src/components/amount_placeholder.tsx b/packages/instant/src/components/amount_placeholder.tsx
index 6ef8f0ac3..29ce8fafb 100644
--- a/packages/instant/src/components/amount_placeholder.tsx
+++ b/packages/instant/src/components/amount_placeholder.tsx
@@ -4,7 +4,7 @@ import { ColorOption } from '../style/theme';
import { Pulse } from './animations/pulse';
-import { Text } from './ui';
+import { Text } from './ui/text';
interface PlainPlaceholder {
color: ColorOption;
diff --git a/packages/instant/src/components/animations/position_animation.tsx b/packages/instant/src/components/animations/position_animation.tsx
new file mode 100644
index 000000000..8b3b294b7
--- /dev/null
+++ b/packages/instant/src/components/animations/position_animation.tsx
@@ -0,0 +1,110 @@
+import { InterpolationValue } from 'styled-components';
+
+import { media, OptionallyScreenSpecific, stylesForMedia } from '../../style/media';
+import { css, keyframes, styled } from '../../style/theme';
+
+export interface TransitionInfo {
+ from: string;
+ to: string;
+}
+
+const generateTransitionInfoCss = (
+ key: keyof TransitionInfo,
+ top?: TransitionInfo,
+ bottom?: TransitionInfo,
+ left?: TransitionInfo,
+ right?: TransitionInfo,
+): string => {
+ const topStringIfExists = top ? `top: ${top[key]};` : '';
+ const bottomStringIfExists = bottom ? `bottom: ${bottom[key]};` : '';
+ const leftStringIfExists = left ? `left: ${left[key]};` : '';
+ const rightStringIfExists = right ? `right: ${right[key]};` : '';
+ return `
+ ${topStringIfExists}
+ ${bottomStringIfExists}
+ ${leftStringIfExists}
+ ${rightStringIfExists}
+ `;
+};
+
+const slideKeyframeGenerator = (
+ position: string,
+ top?: TransitionInfo,
+ bottom?: TransitionInfo,
+ left?: TransitionInfo,
+ right?: TransitionInfo,
+) => keyframes`
+ from {
+ position: ${position};
+ ${generateTransitionInfoCss('from', top, bottom, left, right)}
+ }
+
+ to {
+ position: ${position};
+ ${generateTransitionInfoCss('to', top, bottom, left, right)}
+ }
+`;
+
+export interface PositionAnimationSettings {
+ top?: TransitionInfo;
+ bottom?: TransitionInfo;
+ left?: TransitionInfo;
+ right?: TransitionInfo;
+ timingFunction: string;
+ duration?: string;
+ position?: string;
+}
+
+const generatePositionAnimationCss = (positionSettings: PositionAnimationSettings) => {
+ return css`
+ animation-name: ${slideKeyframeGenerator(
+ positionSettings.position || 'relative',
+ positionSettings.top,
+ positionSettings.bottom,
+ positionSettings.left,
+ positionSettings.right,
+ )};
+ animation-duration: ${positionSettings.duration || '0.3s'};
+ animation-timing-function: ${positionSettings.timingFunction};
+ animation-delay: 0s;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ position: ${positionSettings.position || 'relative'};
+ width: 100%;
+ `;
+};
+
+export interface PositionAnimationProps {
+ positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>;
+ zIndex?: OptionallyScreenSpecific<number>;
+ height?: string;
+}
+
+const defaultAnimation = (positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>) => {
+ const bestDefault = 'default' in positionSettings ? positionSettings.default : positionSettings;
+ return generatePositionAnimationCss(bestDefault);
+};
+const animationForSize = (
+ positionSettings: OptionallyScreenSpecific<PositionAnimationSettings>,
+ sizeKey: 'sm' | 'md' | 'lg',
+ mediaFn: (...args: any[]) => InterpolationValue[],
+) => {
+ // checking default makes sure we have a PositionAnimationSettings object
+ // and then we check to see if we have a setting for the specific `sizeKey`
+ const animationSettingsForSize = 'default' in positionSettings && positionSettings[sizeKey];
+ return animationSettingsForSize && mediaFn`${generatePositionAnimationCss(animationSettingsForSize)}`;
+};
+
+export const PositionAnimation =
+ styled.div <
+ PositionAnimationProps >
+ `
+ && {
+ ${props => props.zIndex && stylesForMedia<number>('z-index', props.zIndex)}
+ ${props => defaultAnimation(props.positionSettings)}
+ ${props => animationForSize(props.positionSettings, 'sm', media.small)}
+ ${props => animationForSize(props.positionSettings, 'md', media.medium)}
+ ${props => animationForSize(props.positionSettings, 'lg', media.large)}
+ ${props => (props.height ? `height: ${props.height};` : '')}
+ }
+`;
diff --git a/packages/instant/src/components/animations/slide_animation.tsx b/packages/instant/src/components/animations/slide_animation.tsx
new file mode 100644
index 000000000..9adb1c674
--- /dev/null
+++ b/packages/instant/src/components/animations/slide_animation.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+
+import { OptionallyScreenSpecific } from '../../style/media';
+
+import { PositionAnimation, PositionAnimationSettings } from './position_animation';
+
+export type SlideAnimationState = 'slidIn' | 'slidOut' | 'none';
+export interface SlideAnimationProps {
+ animationState: SlideAnimationState;
+ slideInSettings: OptionallyScreenSpecific<PositionAnimationSettings>;
+ slideOutSettings: OptionallyScreenSpecific<PositionAnimationSettings>;
+ zIndex?: OptionallyScreenSpecific<number>;
+ height?: string;
+}
+
+export const SlideAnimation: React.StatelessComponent<SlideAnimationProps> = props => {
+ if (props.animationState === 'none') {
+ return <React.Fragment>{props.children}</React.Fragment>;
+ }
+ const positionSettings = props.animationState === 'slidIn' ? props.slideInSettings : props.slideOutSettings;
+ return (
+ <PositionAnimation height={props.height} positionSettings={positionSettings} zIndex={props.zIndex}>
+ {props.children}
+ </PositionAnimation>
+ );
+};
diff --git a/packages/instant/src/components/animations/slide_animations.tsx b/packages/instant/src/components/animations/slide_animations.tsx
deleted file mode 100644
index 1f10a2ed6..000000000
--- a/packages/instant/src/components/animations/slide_animations.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import * as React from 'react';
-
-import { keyframes, styled } from '../../style/theme';
-
-const slideKeyframeGenerator = (fromY: string, toY: string) => keyframes`
- from {
- position: relative;
- top: ${fromY};
- }
-
- to {
- position: relative;
- top: ${toY};
- }
-`;
-
-export interface SlideAnimationProps {
- keyframes: string;
- animationType: string;
- animationDirection?: string;
-}
-
-export const SlideAnimation =
- styled.div <
- SlideAnimationProps >
- `
- animation-name: ${props => props.keyframes};
- animation-duration: 0.3s;
- animation-timing-function: ${props => props.animationType};
- animation-delay: 0s;
- animation-iteration-count: 1;
- animation-fill-mode: ${props => props.animationDirection || 'none'};
- position: relative;
-`;
-
-export interface SlideAnimationComponentProps {
- downY: string;
-}
-
-export const SlideUpAnimation: React.StatelessComponent<SlideAnimationComponentProps> = props => (
- <SlideAnimation animationType="ease-in" keyframes={slideKeyframeGenerator(props.downY, '0px')}>
- {props.children}
- </SlideAnimation>
-);
-
-export const SlideDownAnimation: React.StatelessComponent<SlideAnimationComponentProps> = props => (
- <SlideAnimation
- animationDirection="forwards"
- animationType="cubic-bezier(0.25, 0.1, 0.25, 1)"
- keyframes={slideKeyframeGenerator('0px', props.downY)}
- >
- {props.children}
- </SlideAnimation>
-);
diff --git a/packages/instant/src/components/asset_amount_input.tsx b/packages/instant/src/components/asset_amount_input.tsx
deleted file mode 100644
index c03ef1cf3..000000000
--- a/packages/instant/src/components/asset_amount_input.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { BigNumber } from '@0x/utils';
-import * as _ from 'lodash';
-import * as React from 'react';
-
-import { ColorOption } from '../style/theme';
-import { ERC20Asset } from '../types';
-import { assetUtils } from '../util/asset';
-import { util } from '../util/util';
-
-import { AmountInput, AmountInputProps } from './amount_input';
-import { Container, Text } from './ui';
-
-// Asset amounts only apply to ERC20 assets
-export interface AssetAmountInputProps extends AmountInputProps {
- asset?: ERC20Asset;
- onChange: (value?: BigNumber, asset?: ERC20Asset) => void;
-}
-
-export class AssetAmountInput extends React.Component<AssetAmountInputProps> {
- public static defaultProps = {
- onChange: util.boundNoop,
- };
- public render(): React.ReactNode {
- const { asset, onChange, ...rest } = this.props;
- return (
- <Container>
- <AmountInput {...rest} onChange={this._handleChange} />
- <Container display="inline-block" marginLeft="10px">
- <Text fontSize={rest.fontSize} fontColor={ColorOption.white} textTransform="uppercase">
- {assetUtils.bestNameForAsset(asset)}
- </Text>
- </Container>
- </Container>
- );
- }
- private readonly _handleChange = (value?: BigNumber): void => {
- this.props.onChange(value, this.props.asset);
- };
-}
diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx
index a70269dde..877ab275c 100644
--- a/packages/instant/src/components/buy_button.tsx
+++ b/packages/instant/src/components/buy_button.tsx
@@ -1,20 +1,29 @@
import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
+import { BigNumber } from '@0x/utils';
+import { Web3Wrapper } from '@0x/web3-wrapper';
import * as _ from 'lodash';
import * as React from 'react';
+import { oc } from 'ts-optchain';
import { WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX } from '../constants';
import { ColorOption } from '../style/theme';
+import { AffiliateInfo, ZeroExInstantError } from '../types';
+import { gasPriceEstimator } from '../util/gas_price_estimator';
import { util } from '../util/util';
-import { web3Wrapper } from '../util/web3_wrapper';
-import { Button, Text } from './ui';
+import { Button } from './ui/button';
export interface BuyButtonProps {
+ accountAddress?: string;
+ accountEthBalanceInWei?: BigNumber;
buyQuote?: BuyQuote;
- assetBuyer?: AssetBuyer;
- onAwaitingSignature: (buyQuote: BuyQuote) => void;
- onSignatureDenied: (buyQuote: BuyQuote, preventedError: Error) => void;
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void;
+ assetBuyer: AssetBuyer;
+ web3Wrapper: Web3Wrapper;
+ affiliateInfo?: AffiliateInfo;
+ onValidationPending: (buyQuote: BuyQuote) => void;
+ onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void;
+ onSignatureDenied: (buyQuote: BuyQuote) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
}
@@ -26,35 +35,58 @@ export class BuyButton extends React.Component<BuyButtonProps> {
onBuyFailure: util.boundNoop,
};
public render(): React.ReactNode {
- const shouldDisableButton = _.isUndefined(this.props.buyQuote) || _.isUndefined(this.props.assetBuyer);
+ const { buyQuote, accountAddress } = this.props;
+ const shouldDisableButton = _.isUndefined(buyQuote) || _.isUndefined(accountAddress);
return (
- <Button width="100%" onClick={this._handleClick} isDisabled={shouldDisableButton}>
- <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px">
- Buy
- </Text>
+ <Button
+ width="100%"
+ onClick={this._handleClick}
+ isDisabled={shouldDisableButton}
+ fontColor={ColorOption.white}
+ fontSize="20px"
+ >
+ Buy
</Button>
);
}
private readonly _handleClick = async () => {
// The button is disabled when there is no buy quote anyway.
- const { buyQuote, assetBuyer } = this.props;
- if (_.isUndefined(buyQuote) || _.isUndefined(assetBuyer)) {
+ const { buyQuote, assetBuyer, affiliateInfo, accountAddress, accountEthBalanceInWei, web3Wrapper } = this.props;
+ if (_.isUndefined(buyQuote) || _.isUndefined(accountAddress)) {
+ return;
+ }
+ this.props.onValidationPending(buyQuote);
+ const ethNeededForBuy = buyQuote.worstCaseQuoteInfo.totalEthAmount;
+ // if we don't have a balance for the user, let the transaction through, it will be handled by the wallet
+ const hasSufficientEth = _.isUndefined(accountEthBalanceInWei) || accountEthBalanceInWei.gte(ethNeededForBuy);
+ if (!hasSufficientEth) {
+ this.props.onValidationFail(buyQuote, ZeroExInstantError.InsufficientETH);
return;
}
-
let txHash: string | undefined;
- this.props.onAwaitingSignature(buyQuote);
+ const gasInfo = await gasPriceEstimator.getGasInfoAsync();
+ const feeRecipient = oc(affiliateInfo).feeRecipient();
try {
- txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote);
+ txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, {
+ feeRecipient,
+ takerAddress: accountAddress,
+ gasPrice: gasInfo.gasPriceInWei,
+ });
} catch (e) {
- if (e instanceof Error && e.message === AssetBuyerError.SignatureRequestDenied) {
- this.props.onSignatureDenied(buyQuote, e);
- return;
+ 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;
}
-
- this.props.onBuyProcessing(buyQuote, txHash);
+ const startTimeUnix = new Date().getTime();
+ const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs;
+ this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix);
try {
await web3Wrapper.awaitTransactionSuccessAsync(txHash);
} catch (e) {
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_button.tsx b/packages/instant/src/components/buy_order_state_button.tsx
deleted file mode 100644
index 44115e5a1..000000000
--- a/packages/instant/src/components/buy_order_state_button.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as React from 'react';
-
-import { PlacingOrderButton } from '../components/placing_order_button';
-import { SelectedAssetBuyButton } from '../containers/selected_asset_buy_button';
-import { SelectedAssetRetryButton } from '../containers/selected_asset_retry_button';
-import { SelectedAssetViewTransactionButton } from '../containers/selected_asset_view_transaction_button';
-import { OrderProcessState } from '../types';
-
-export interface BuyOrderStateButtonProps {
- buyOrderProcessingState: OrderProcessState;
-}
-
-export const BuyOrderStateButton: React.StatelessComponent<BuyOrderStateButtonProps> = props => {
- if (props.buyOrderProcessingState === OrderProcessState.FAILURE) {
- return <SelectedAssetRetryButton />;
- } else if (
- props.buyOrderProcessingState === OrderProcessState.SUCCESS ||
- props.buyOrderProcessingState === OrderProcessState.PROCESSING
- ) {
- return <SelectedAssetViewTransactionButton />;
- } else if (props.buyOrderProcessingState === OrderProcessState.AWAITING_SIGNATURE) {
- return <PlacingOrderButton />;
- }
-
- return <SelectedAssetBuyButton />;
-};
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..6041bf4f5
--- /dev/null
+++ b/packages/instant/src/components/buy_order_state_buttons.tsx
@@ -0,0 +1,71 @@
+import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
+import { BigNumber } from '@0x/utils';
+import { Web3Wrapper } from '@0x/web3-wrapper';
+import * as React from 'react';
+
+import { ColorOption } from '../style/theme';
+import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types';
+
+import { BuyButton } from './buy_button';
+import { PlacingOrderButton } from './placing_order_button';
+import { SecondaryButton } from './secondary_button';
+
+import { Button } from './ui/button';
+import { Flex } from './ui/flex';
+
+export interface BuyOrderStateButtonProps {
+ accountAddress?: string;
+ accountEthBalanceInWei?: BigNumber;
+ buyQuote?: BuyQuote;
+ buyOrderProcessingState: OrderProcessState;
+ assetBuyer: AssetBuyer;
+ web3Wrapper: Web3Wrapper;
+ affiliateInfo?: AffiliateInfo;
+ onViewTransaction: () => void;
+ onValidationPending: (buyQuote: BuyQuote) => void;
+ onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void;
+ onSignatureDenied: (buyQuote: BuyQuote) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
+ onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
+ onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
+ onRetry: () => void;
+}
+
+export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonProps> = props => {
+ if (props.buyOrderProcessingState === OrderProcessState.Failure) {
+ return (
+ <Flex justify="space-between">
+ <Button width="48%" onClick={props.onRetry} fontColor={ColorOption.white} fontSize="16px">
+ Back
+ </Button>
+ <SecondaryButton width="48%" onClick={props.onViewTransaction}>
+ Details
+ </SecondaryButton>
+ </Flex>
+ );
+ } else if (
+ props.buyOrderProcessingState === OrderProcessState.Success ||
+ props.buyOrderProcessingState === OrderProcessState.Processing
+ ) {
+ return <SecondaryButton onClick={props.onViewTransaction}>View Transaction</SecondaryButton>;
+ } else if (props.buyOrderProcessingState === OrderProcessState.Validating) {
+ return <PlacingOrderButton />;
+ }
+
+ return (
+ <BuyButton
+ accountAddress={props.accountAddress}
+ accountEthBalanceInWei={props.accountEthBalanceInWei}
+ buyQuote={props.buyQuote}
+ assetBuyer={props.assetBuyer}
+ web3Wrapper={props.web3Wrapper}
+ affiliateInfo={props.affiliateInfo}
+ onValidationPending={props.onValidationPending}
+ onValidationFail={props.onValidationFail}
+ onSignatureDenied={props.onSignatureDenied}
+ onBuyProcessing={props.onBuyProcessing}
+ onBuySuccess={props.onBuySuccess}
+ onBuyFailure={props.onBuyFailure}
+ />
+ );
+};
diff --git a/packages/instant/src/components/css_reset.tsx b/packages/instant/src/components/css_reset.tsx
new file mode 100644
index 000000000..0bef85389
--- /dev/null
+++ b/packages/instant/src/components/css_reset.tsx
@@ -0,0 +1,32 @@
+import { INJECTED_DIV_CLASS } from '../constants';
+import { createGlobalStyle } from '../style/theme';
+
+export interface CSSResetProps {}
+
+/*
+* Derived from
+* https://github.com/jtrost/Complete-CSS-Reset
+*/
+export const CSSReset = createGlobalStyle`
+ .${INJECTED_DIV_CLASS} {
+ a, abbr, area, article, aside, audio, b, bdo, blockquote, body, button,
+ canvas, caption, cite, code, col, colgroup, command, datalist, dd, del,
+ details, dialog, dfn, div, dl, dt, em, embed, fieldset, figure, form,
+ h1, h2, h3, h4, h5, h6, head, header, hgroup, hr, html, i, iframe, img,
+ input, ins, keygen, kbd, label, legend, li, map, mark, menu, meter, nav,
+ noscript, object, ol, optgroup, option, output, p, param, pre, progress,
+ q, rp, rt, ruby, samp, section, select, small, span, strong, sub, sup,
+ table, tbody, td, textarea, tfoot, th, thead, time, tr, ul, var, video {
+ background: transparent;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ margin: 0;
+ outline: none;
+ padding: 0;
+ text-align: left;
+ text-decoration: none;
+ vertical-align: baseline;
+ }
+ }
+`;
diff --git a/packages/instant/src/components/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx
new file mode 100644
index 000000000..520ac33d5
--- /dev/null
+++ b/packages/instant/src/components/erc20_asset_amount_input.tsx
@@ -0,0 +1,161 @@
+import { BigNumber } from '@0x/utils';
+import * as _ from 'lodash';
+import * as React from 'react';
+
+import { ColorOption, transparentWhite } from '../style/theme';
+import { ERC20Asset, SimpleHandler } from '../types';
+import { assetUtils } from '../util/asset';
+import { util } from '../util/util';
+
+import { ScalingAmountInput } from './scaling_amount_input';
+
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
+import { Icon } from './ui/icon';
+import { Text } from './ui/text';
+
+// Asset amounts only apply to ERC20 assets
+export interface ERC20AssetAmountInputProps {
+ asset?: ERC20Asset;
+ value?: BigNumber;
+ onChange: (value?: BigNumber, asset?: ERC20Asset) => void;
+ onSelectAssetClick?: (asset?: ERC20Asset) => void;
+ startingFontSizePx: number;
+ fontColor?: ColorOption;
+ isDisabled: boolean;
+ numberOfAssetsAvailable?: number;
+}
+
+export interface ERC20AssetAmountInputState {
+ currentFontSizePx: number;
+}
+
+export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInputProps, ERC20AssetAmountInputState> {
+ public static defaultProps = {
+ onChange: util.boundNoop,
+ isDisabled: false,
+ };
+ constructor(props: ERC20AssetAmountInputProps) {
+ super(props);
+ this.state = {
+ currentFontSizePx: props.startingFontSizePx,
+ };
+ }
+ public render(): React.ReactNode {
+ const { asset } = this.props;
+ return (
+ <Container whiteSpace="nowrap">
+ {_.isUndefined(asset) ? this._renderTokenSelectionContent() : this._renderContentForAsset(asset)}
+ </Container>
+ );
+ }
+ private readonly _renderContentForAsset = (asset: ERC20Asset): React.ReactNode => {
+ const { onChange, ...rest } = this.props;
+ const amountBorderBottom = this.props.isDisabled ? '' : `1px solid ${transparentWhite}`;
+ const onSymbolClick = this._generateSelectAssetClickHandler();
+ return (
+ <React.Fragment>
+ <Container borderBottom={amountBorderBottom} display="inline-block">
+ <ScalingAmountInput
+ {...rest}
+ textLengthThreshold={this._textLengthThresholdForAsset(asset)}
+ maxFontSizePx={this.props.startingFontSizePx}
+ onAmountChange={this._handleChange}
+ onFontSizeChange={this._handleFontSizeChange}
+ />
+ </Container>
+ <Container
+ display="inline-block"
+ marginLeft="8px"
+ title={assetUtils.bestNameForAsset(asset, undefined)}
+ >
+ <Flex inline={true}>
+ <Text
+ fontSize={`${this.state.currentFontSizePx}px`}
+ fontColor={ColorOption.white}
+ textTransform="uppercase"
+ onClick={onSymbolClick}
+ >
+ {assetUtils.formattedSymbolForAsset(asset)}
+ </Text>
+ {this._renderChevronIcon()}
+ </Flex>
+ </Container>
+ </React.Fragment>
+ );
+ };
+ private readonly _renderTokenSelectionContent = (): React.ReactNode => {
+ const { numberOfAssetsAvailable } = this.props;
+ let text = 'Select Token';
+ if (_.isUndefined(numberOfAssetsAvailable)) {
+ text = 'Loading...';
+ } else if (numberOfAssetsAvailable === 0) {
+ text = 'Assets Unavailable';
+ }
+ return (
+ <Flex>
+ <Text
+ fontSize="30px"
+ fontColor={ColorOption.white}
+ opacity={0.7}
+ fontWeight="500"
+ onClick={this._generateSelectAssetClickHandler()}
+ >
+ {text}
+ </Text>
+ {this._renderChevronIcon()}
+ </Flex>
+ );
+ };
+ private readonly _renderChevronIcon = (): React.ReactNode => {
+ if (!this._areMultipleAssetsAvailable()) {
+ return null;
+ }
+ return (
+ <Container marginLeft="5px">
+ <Icon icon="chevron" width={12} stroke={ColorOption.white} onClick={this._handleSelectAssetClick} />
+ </Container>
+ );
+ };
+ private readonly _handleChange = (value?: BigNumber): void => {
+ this.props.onChange(value, this.props.asset);
+ };
+ private readonly _handleFontSizeChange = (fontSizePx: number): void => {
+ this.setState({
+ currentFontSizePx: fontSizePx,
+ });
+ };
+ private readonly _generateSelectAssetClickHandler = (): SimpleHandler | undefined => {
+ // We don't want to allow opening the token selection panel if there are no assets.
+ // Since styles are inferred from the presence of a click handler, we want to return undefined
+ // instead of providing a noop.
+ if (!this._areMultipleAssetsAvailable() || _.isUndefined(this.props.onSelectAssetClick)) {
+ return undefined;
+ }
+ return this._handleSelectAssetClick;
+ };
+ private readonly _areMultipleAssetsAvailable = (): boolean => {
+ const { numberOfAssetsAvailable } = this.props;
+ return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 1;
+ };
+ private readonly _handleSelectAssetClick = (): void => {
+ if (this.props.onSelectAssetClick) {
+ this.props.onSelectAssetClick();
+ }
+ };
+ // For assets with symbols of different length,
+ // start scaling the input at different character lengths
+ private readonly _textLengthThresholdForAsset = (asset?: ERC20Asset): number => {
+ if (_.isUndefined(asset)) {
+ return 3;
+ }
+ const symbol = asset.metaData.symbol;
+ if (symbol.length <= 3) {
+ return 5;
+ }
+ if (symbol.length === 5) {
+ return 3;
+ }
+ return 4;
+ };
+}
diff --git a/packages/instant/src/components/erc20_token_selector.tsx b/packages/instant/src/components/erc20_token_selector.tsx
new file mode 100644
index 000000000..3503ff31a
--- /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 height="100%">
+ <SearchInput
+ placeholder="Search tokens..."
+ width="100%"
+ value={this.state.searchQuery}
+ onChange={this._handleSearchInputChange}
+ />
+ <Container overflow="scroll" height="calc(100% - 80px)" 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} rawColor={token.metaData.primaryColor}>
+ <Flex height="100%">
+ <Text fontColor={ColorOption.white} fontSize="8px">
+ {displaySymbol}
+ </Text>
+ </Flex>
+ </Circle>
+ </Container>
+ <Text fontSize="14px" fontWeight={700} fontColor={ColorOption.black}>
+ {displaySymbol}
+ </Text>
+ <Container margin="0px 5px">
+ <Text fontSize="14px"> - </Text>
+ </Container>
+ <Text fontSize="14px">{token.metaData.name}</Text>
+ </Flex>
+ </Container>
+ </Container>
+ );
+ }
+ private readonly _handleClick = (): void => {
+ this.props.onClick(this.props.token);
+ };
+}
diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx
index 17ac65429..b07776b2c 100644
--- a/packages/instant/src/components/instant_heading.tsx
+++ b/packages/instant/src/components/instant_heading.tsx
@@ -2,15 +2,17 @@ import { BigNumber } from '@0x/utils';
import * as _ from 'lodash';
import * as React from 'react';
-import { SelectedAssetAmountInput } from '../containers/selected_asset_amount_input';
+import { SelectedERC20AssetAmountInput } from '../containers/selected_erc20_asset_amount_input';
import { ColorOption } from '../style/theme';
-import { AsyncProcessState, OrderProcessState, OrderState } from '../types';
+import { AsyncProcessState, ERC20Asset, OrderProcessState, OrderState } from '../types';
import { format } from '../util/format';
import { AmountPlaceholder } from './amount_placeholder';
-import { Container, Flex, Text } from './ui';
+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;
@@ -18,6 +20,7 @@ export interface InstantHeadingProps {
ethUsdPrice?: BigNumber;
quoteRequestState: AsyncProcessState;
buyOrderState: OrderState;
+ onSelectAssetClick?: (asset?: ERC20Asset) => void;
}
const PLACEHOLDER_COLOR = ColorOption.white;
@@ -48,7 +51,12 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> {
</Text>
</Container>
<Flex direction="row" justify="space-between">
- <SelectedAssetAmountInput fontSize="45px" />
+ <Flex height="60px">
+ <SelectedERC20AssetAmountInput
+ startingFontSizePx={38}
+ onSelectAssetClick={this.props.onSelectAssetClick}
+ />
+ </Flex>
<Flex direction="column" justify="space-between">
{iconOrAmounts}
</Flex>
@@ -60,8 +68,8 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> {
private _renderAmountsSection(): React.ReactNode {
return (
<Container>
- <Container marginBottom="5px">{this._placeholderOrAmount(this._ethAmount)}</Container>
- <Container opacity={0.7}>{this._placeholderOrAmount(this._dollarAmount)}</Container>
+ <Container marginBottom="5px">{this._renderPlaceholderOrAmount(this._renderEthAmount)}</Container>
+ <Container opacity={0.7}>{this._renderPlaceholderOrAmount(this._renderDollarAmount)}</Container>
</Container>
);
}
@@ -69,31 +77,31 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> {
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) {
+ 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} />;
+ } 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) {
+ if (processState === OrderProcessState.Failure) {
return 'Order failed';
- } else if (processState === OrderProcessState.PROCESSING) {
+ } else if (processState === OrderProcessState.Processing) {
return 'Processing Order...';
- } else if (processState === OrderProcessState.SUCCESS) {
+ } else if (processState === OrderProcessState.Success) {
return 'Tokens received!';
}
return 'I want to buy';
}
- private _placeholderOrAmount(amountFunction: () => React.ReactNode): React.ReactNode {
- if (this.props.quoteRequestState === AsyncProcessState.PENDING) {
+ 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)) {
@@ -102,7 +110,7 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> {
return amountFunction();
}
- private readonly _ethAmount = (): React.ReactNode => {
+ private readonly _renderEthAmount = (): React.ReactNode => {
return (
<Text fontSize="16px" fontColor={ColorOption.white} fontWeight={500}>
{format.ethBaseAmount(
@@ -114,7 +122,7 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> {
);
};
- private readonly _dollarAmount = (): React.ReactNode => {
+ private readonly _renderDollarAmount = (): React.ReactNode => {
return (
<Text fontSize="16px" fontColor={ColorOption.white}>
{format.ethBaseAmountInUsd(
diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx
index 704009d89..9abd7137e 100644
--- a/packages/instant/src/components/order_details.tsx
+++ b/packages/instant/src/components/order_details.tsx
@@ -8,7 +8,10 @@ import { ColorOption } from '../style/theme';
import { format } from '../util/format';
import { AmountPlaceholder } from './amount_placeholder';
-import { Container, Flex, Text } from './ui';
+
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
+import { Text } from './ui/text';
export interface OrderDetailsProps {
buyQuoteInfo?: BuyQuoteInfo;
@@ -23,7 +26,7 @@ export class OrderDetails extends React.Component<OrderDetailsProps> {
const ethTokenFee = buyQuoteAccessor.feeEthAmount();
const totalEthAmount = buyQuoteAccessor.totalEthAmount();
return (
- <Container padding="20px" width="100%">
+ <Container padding="20px" width="100%" flexGrow={1}>
<Container marginBottom="10px">
<Text
letterSpacing="1px"
diff --git a/packages/instant/src/components/payment_method.tsx b/packages/instant/src/components/payment_method.tsx
new file mode 100644
index 000000000..8c0b47d72
--- /dev/null
+++ b/packages/instant/src/components/payment_method.tsx
@@ -0,0 +1,45 @@
+import { BigNumber } from '@0x/utils';
+import * as _ from 'lodash';
+import * as React from 'react';
+
+import { ColorOption } from '../style/theme';
+import { Network } from '../types';
+
+import { PaymentMethodDropdown } from './payment_method_dropdown';
+import { Circle } from './ui/circle';
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
+import { Text } from './ui/text';
+
+export interface PaymentMethodProps {}
+
+export const PaymentMethod: React.StatelessComponent<PaymentMethodProps> = () => (
+ <Container padding="20px" width="100%">
+ <Container marginBottom="10px">
+ <Flex justify="space-between">
+ <Text
+ letterSpacing="1px"
+ fontColor={ColorOption.primaryColor}
+ fontWeight={600}
+ textTransform="uppercase"
+ fontSize="14px"
+ >
+ Payment Method
+ </Text>
+ <Flex>
+ <Circle color={ColorOption.green} diameter={8} />
+ <Container marginLeft="3px">
+ <Text fontColor={ColorOption.darkGrey} fontSize="12px">
+ MetaMask
+ </Text>
+ </Container>
+ </Flex>
+ </Flex>
+ </Container>
+ <PaymentMethodDropdown
+ accountAddress="0xa1b2c3d4e5f6g7h8j9k10"
+ accountEthBalanceInWei={new BigNumber(10500000000000000000)}
+ network={Network.Mainnet}
+ />
+ </Container>
+);
diff --git a/packages/instant/src/components/payment_method_dropdown.tsx b/packages/instant/src/components/payment_method_dropdown.tsx
new file mode 100644
index 000000000..bdce2a49d
--- /dev/null
+++ b/packages/instant/src/components/payment_method_dropdown.tsx
@@ -0,0 +1,44 @@
+import { BigNumber } from '@0x/utils';
+import copy from 'copy-to-clipboard';
+import * as React from 'react';
+
+import { Network } from '../types';
+import { etherscanUtil } from '../util/etherscan';
+import { format } from '../util/format';
+
+import { Dropdown, DropdownItemConfig } from './ui/dropdown';
+
+export interface PaymentMethodDropdownProps {
+ accountAddress: string;
+ accountEthBalanceInWei?: BigNumber;
+ network: Network;
+}
+
+export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdownProps> {
+ public render(): React.ReactNode {
+ const { accountAddress, accountEthBalanceInWei } = this.props;
+ const value = format.ethAddress(accountAddress);
+ const label = format.ethBaseAmount(accountEthBalanceInWei, 4, '') as string;
+ return <Dropdown value={value} label={label} items={this._getDropdownItemConfigs()} />;
+ }
+ private readonly _getDropdownItemConfigs = (): DropdownItemConfig[] => {
+ const viewOnEtherscan = {
+ text: 'View on Etherscan',
+ onClick: this._handleEtherscanClick,
+ };
+ const copyAddressToClipboard = {
+ text: 'Copy address to clipboard',
+ onClick: this._handleCopyToClipboardClick,
+ };
+ return [viewOnEtherscan, copyAddressToClipboard];
+ };
+ private readonly _handleEtherscanClick = (): void => {
+ const { accountAddress, network } = this.props;
+ const etherscanUrl = etherscanUtil.getEtherScanEthAddressIfExists(accountAddress, network);
+ window.open(etherscanUrl, '_blank');
+ };
+ private readonly _handleCopyToClipboardClick = (): void => {
+ const { accountAddress } = this.props;
+ copy(accountAddress);
+ };
+}
diff --git a/packages/instant/src/components/placing_order_button.tsx b/packages/instant/src/components/placing_order_button.tsx
index 4232e6c22..d774d7d27 100644
--- a/packages/instant/src/components/placing_order_button.tsx
+++ b/packages/instant/src/components/placing_order_button.tsx
@@ -5,15 +5,12 @@ 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%">
+ <Button isDisabled={true} width="100%" fontColor={ColorOption.white} fontSize="20px">
<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&hellip;
- </Text>
+ Placing Order&hellip;
</Button>
);
diff --git a/packages/instant/src/components/retry_button.tsx b/packages/instant/src/components/retry_button.tsx
deleted file mode 100644
index 0d6188e6a..000000000
--- a/packages/instant/src/components/retry_button.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as React from 'react';
-
-import { SecondaryButton } from './secondary_button';
-
-export interface RetryButtonProps {
- onClick: () => void;
-}
-
-export const RetryButton: React.StatelessComponent<RetryButtonProps> = props => {
- return <SecondaryButton onClick={props.onClick}>Try Again</SecondaryButton>;
-};
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
index 3c139a233..df0539606 100644
--- a/packages/instant/src/components/secondary_button.tsx
+++ b/packages/instant/src/components/secondary_button.tsx
@@ -4,7 +4,6 @@ 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 {}
@@ -14,13 +13,16 @@ export const SecondaryButton: React.StatelessComponent<SecondaryButtonProps> = p
<Button
backgroundColor={ColorOption.white}
borderColor={ColorOption.lightGrey}
- width="100%"
+ width={props.width}
onClick={props.onClick}
+ fontColor={ColorOption.primaryColor}
+ fontSize="16px"
{...buttonProps}
>
- <Text fontColor={ColorOption.primaryColor} fontWeight={600} fontSize="16px">
- {props.children}
- </Text>
+ {props.children}
</Button>
);
};
+SecondaryButton.defaultProps = {
+ width: '100%',
+};
diff --git a/packages/instant/src/components/sliding_error.tsx b/packages/instant/src/components/sliding_error.tsx
index 3865a8797..a8d4e391c 100644
--- a/packages/instant/src/components/sliding_error.tsx
+++ b/packages/instant/src/components/sliding_error.tsx
@@ -1,10 +1,15 @@
import * as React from 'react';
+import { ScreenSpecification } from '../style/media';
import { ColorOption } from '../style/theme';
+import { zIndex } from '../style/z_index';
-import { SlideDownAnimation, SlideUpAnimation } from './animations/slide_animations';
+import { PositionAnimationSettings } from './animations/position_animation';
+import { SlideAnimation, SlideAnimationState } from './animations/slide_animation';
-import { Container, Text } from './ui';
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
+import { Text } from './ui/text';
export interface ErrorProps {
icon: string;
@@ -18,27 +23,73 @@ export const Error: React.StatelessComponent<ErrorProps> = props => (
backgroundColor={ColorOption.lightOrange}
width="100%"
borderRadius="6px"
+ marginTop="10px"
marginBottom="10px"
>
- <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 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 type SlidingDirection = 'up' | 'down';
export interface SlidingErrorProps extends ErrorProps {
- direction: SlidingDirection;
+ animationState: SlideAnimationState;
}
export const SlidingError: React.StatelessComponent<SlidingErrorProps> = props => {
- const AnimationComponent = props.direction === 'up' ? SlideUpAnimation : SlideDownAnimation;
+ const slideAmount = '120px';
+
+ const desktopSlideIn: PositionAnimationSettings = {
+ timingFunction: 'ease-in',
+ top: {
+ from: slideAmount,
+ to: '0px',
+ },
+ position: 'relative',
+ };
+ const desktopSlideOut: PositionAnimationSettings = {
+ timingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
+ top: {
+ from: '0px',
+ to: slideAmount,
+ },
+ position: 'relative',
+ };
+
+ const mobileSlideIn: PositionAnimationSettings = {
+ duration: '0.5s',
+ timingFunction: 'ease-in',
+ top: { from: '-120px', to: '0px' },
+ position: 'fixed',
+ };
+ const moblieSlideOut: PositionAnimationSettings = {
+ duration: '0.5s',
+ timingFunction: 'ease-in',
+ top: { from: '0px', to: '-120px' },
+ position: 'fixed',
+ };
+
+ const slideUpSettings: ScreenSpecification<PositionAnimationSettings> = {
+ default: desktopSlideIn,
+ sm: mobileSlideIn,
+ };
+ const slideOutSettings: ScreenSpecification<PositionAnimationSettings> = {
+ default: desktopSlideOut,
+ sm: moblieSlideOut,
+ };
return (
- <AnimationComponent downY="120px">
+ <SlideAnimation
+ slideInSettings={slideUpSettings}
+ slideOutSettings={slideOutSettings}
+ zIndex={{ sm: zIndex.errorPopup, default: zIndex.errorPopBehind }}
+ animationState={props.animationState}
+ >
<Error icon={props.icon} message={props.message} />
- </AnimationComponent>
+ </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..9d16f9560
--- /dev/null
+++ b/packages/instant/src/components/sliding_panel.tsx
@@ -0,0 +1,77 @@
+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" height="100%">
+ {children}
+ </Container>
+ </Container>
+);
+
+export interface SlidingPanelProps extends PanelProps {
+ animationState: SlideAnimationState;
+}
+
+export const SlidingPanel: React.StatelessComponent<SlidingPanelProps> = props => {
+ if (props.animationState === 'none') {
+ return null;
+ }
+ const { animationState, ...rest } = props;
+ const slideAmount = '100%';
+ const slideUpSettings: PositionAnimationSettings = {
+ duration: '0.3s',
+ timingFunction: 'ease-in-out',
+ top: {
+ from: slideAmount,
+ to: '0px',
+ },
+ position: 'absolute',
+ };
+ const slideDownSettings: PositionAnimationSettings = {
+ duration: '0.3s',
+ timingFunction: 'ease-out',
+ top: {
+ from: '0px',
+ to: slideAmount,
+ },
+ position: 'absolute',
+ };
+ return (
+ <SlideAnimation
+ slideInSettings={slideUpSettings}
+ slideOutSettings={slideDownSettings}
+ animationState={animationState}
+ height="100%"
+ >
+ <Panel {...rest} />
+ </SlideAnimation>
+ );
+};
diff --git a/packages/instant/src/components/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..59aaa33a1
--- /dev/null
+++ b/packages/instant/src/components/timed_progress_bar.tsx
@@ -0,0 +1,80 @@
+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
index 1fcb2591c..b90221bf4 100644
--- a/packages/instant/src/components/ui/button.tsx
+++ b/packages/instant/src/components/ui/button.tsx
@@ -6,6 +6,8 @@ import { ColorOption, styled } from '../../style/theme';
export interface ButtonProps {
backgroundColor?: ColorOption;
borderColor?: ColorOption;
+ fontColor?: ColorOption;
+ fontSize?: string;
width?: string;
padding?: string;
type?: string;
@@ -24,37 +26,49 @@ 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'])};
+ && {
+ all: initial;
+ box-sizing: border-box;
+ font-size: ${props => props.fontSize};
+ font-family: 'Inter UI', sans-serif;
+ font-weight: 600;
+ color: ${props => props.fontColor && props.theme[props.fontColor]};
+ cursor: ${props => (props.isDisabled ? 'default' : 'pointer')};
+ transition: background-color, opacity 0.5s ease;
+ padding: ${props => props.padding};
+ border-radius: 3px;
+ text-align: center;
+ outline: none;
+ width: ${props => props.width};
+ background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')};
+ border: ${props => (props.borderColor ? `1px solid ${props.theme[props.borderColor]}` : 'none')};
+ &:hover {
+ background-color: ${props =>
+ !props.isDisabled
+ ? darken(darkenOnHoverAmount, props.theme[props.backgroundColor || 'white'])
+ : ''} !important;
+ }
+ &:active {
+ background-color: ${props =>
+ !props.isDisabled ? darken(darkenOnActiveAmount, props.theme[props.backgroundColor || 'white']) : ''};
+ }
+ &:disabled {
+ opacity: 0.5;
+ }
+ &:focus {
+ background-color: ${props =>
+ saturate(saturateOnFocusAmount, props.theme[props.backgroundColor || 'white'])};
+ }
}
`;
Button.defaultProps = {
backgroundColor: ColorOption.primaryColor,
+ borderColor: ColorOption.primaryColor,
width: 'auto',
isDisabled: false,
- padding: '1em 2.2em',
+ padding: '.6em 1.2em',
+ fontSize: '15px',
};
Button.displayName = 'Button';
diff --git a/packages/instant/src/components/ui/circle.tsx b/packages/instant/src/components/ui/circle.tsx
new file mode 100644
index 000000000..4f9f56f12
--- /dev/null
+++ b/packages/instant/src/components/ui/circle.tsx
@@ -0,0 +1,27 @@
+import { ColorOption, styled, Theme, withTheme } from '../../style/theme';
+
+export interface CircleProps {
+ diameter: number;
+ rawColor?: string;
+ color?: ColorOption;
+ theme: Theme;
+}
+
+export const Circle = withTheme(
+ styled.div <
+ CircleProps >
+ `
+ && {
+ width: ${props => props.diameter}px;
+ height: ${props => props.diameter}px;
+ background-color: ${props => (props.rawColor ? props.rawColor : props.theme[props.color || ColorOption.white])};
+ border-radius: 50%;
+ }
+`,
+);
+
+Circle.displayName = 'Circle';
+
+Circle.defaultProps = {
+ color: ColorOption.white,
+};
diff --git a/packages/instant/src/components/ui/container.tsx b/packages/instant/src/components/ui/container.tsx
index 5e2218c68..8aa5db9e5 100644
--- a/packages/instant/src/components/ui/container.tsx
+++ b/packages/instant/src/components/ui/container.tsx
@@ -1,16 +1,18 @@
-import * as React from 'react';
+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?: string;
+ display?: MediaChoice;
position?: string;
top?: string;
right?: string;
bottom?: string;
left?: string;
- width?: string;
+ width?: MediaChoice;
+ height?: MediaChoice;
maxWidth?: string;
margin?: string;
marginTop?: string;
@@ -27,38 +29,59 @@ export interface ContainerProps {
backgroundColor?: ColorOption;
hasBoxShadow?: boolean;
zIndex?: number;
+ whiteSpace?: string;
opacity?: number;
+ cursor?: string;
+ overflow?: string;
+ darkenOnHover?: boolean;
+ boxShadowOnHover?: boolean;
+ flexGrow?: string | number;
}
-const PlainContainer: React.StatelessComponent<ContainerProps> = ({ children, className }) => (
- <div className={className}>{children}</div>
-);
-
-export const Container = styled(PlainContainer)`
- box-sizing: border-box;
- ${props => cssRuleIfExists(props, 'display')}
- ${props => cssRuleIfExists(props, 'position')}
- ${props => cssRuleIfExists(props, 'top')}
- ${props => cssRuleIfExists(props, 'right')}
- ${props => cssRuleIfExists(props, 'bottom')}
- ${props => cssRuleIfExists(props, 'left')}
- ${props => cssRuleIfExists(props, 'width')}
- ${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, 'opacity')}
- ${props => (props.hasBoxShadow ? `box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1)` : '')};
- background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')};
- border-color: ${props => (props.borderColor ? props.theme[props.borderColor] : 'none')};
+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<string>('display', props.display)}
+ ${props => props.width && stylesForMedia<string>('width', props.width)}
+ ${props => props.height && stylesForMedia<string>('height', props.height)}
+ background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')};
+ border-color: ${props => (props.borderColor ? props.theme[props.borderColor] : 'none')};
+ &:hover {
+ ${props =>
+ props.darkenOnHover
+ ? `background-color: ${
+ props.backgroundColor ? darken(0.05, props.theme[props.backgroundColor]) : 'none'
+ }`
+ : ''};
+ ${props => (props.boxShadowOnHover ? 'box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1)' : '')};
+ }
+ }
`;
Container.defaultProps = {
diff --git a/packages/instant/src/components/ui/dropdown.tsx b/packages/instant/src/components/ui/dropdown.tsx
new file mode 100644
index 000000000..3a23f456d
--- /dev/null
+++ b/packages/instant/src/components/ui/dropdown.tsx
@@ -0,0 +1,134 @@
+import * as _ from 'lodash';
+import * as React from 'react';
+
+import { ColorOption, completelyTransparent } from '../../style/theme';
+import { zIndex } from '../../style/z_index';
+
+import { Container } from './container';
+import { Flex } from './flex';
+import { Icon } from './icon';
+import { Overlay } from './overlay';
+import { Text } from './text';
+
+export interface DropdownItemConfig {
+ text: string;
+ onClick?: () => void;
+}
+
+export interface DropdownProps {
+ value: string;
+ label?: string;
+ items: DropdownItemConfig[];
+}
+
+export interface DropdownState {
+ isOpen: boolean;
+}
+
+export class Dropdown extends React.Component<DropdownProps, DropdownState> {
+ public static defaultProps = {
+ items: [],
+ };
+ public state: DropdownState = {
+ isOpen: false,
+ };
+ public render(): React.ReactNode {
+ const { value, label, items } = this.props;
+ const { isOpen } = this.state;
+ const hasItems = !_.isEmpty(items);
+ const borderRadius = isOpen ? '4px 4px 0px 0px' : '4px';
+ return (
+ <React.Fragment>
+ {isOpen && (
+ <Overlay
+ zIndex={zIndex.dropdownItems - 1}
+ backgroundColor={completelyTransparent}
+ onClick={this._closeDropdown}
+ />
+ )}
+ <Container position="relative">
+ <Container
+ cursor={hasItems ? 'pointer' : undefined}
+ onClick={this._handleDropdownClick}
+ hasBoxShadow={isOpen}
+ boxShadowOnHover={true}
+ borderRadius={borderRadius}
+ border="1px solid"
+ borderColor={ColorOption.feintGrey}
+ padding="0.8em"
+ >
+ <Flex justify="space-between">
+ <Text fontSize="16px" fontColor={ColorOption.darkGrey}>
+ {value}
+ </Text>
+ <Container>
+ {label && (
+ <Text fontSize="16px" fontColor={ColorOption.lightGrey}>
+ {label}
+ </Text>
+ )}
+ {hasItems && (
+ <Container marginLeft="5px" display="inline-block" position="relative" bottom="2px">
+ <Icon padding="3px" icon="chevron" width={12} stroke={ColorOption.grey} />
+ </Container>
+ )}
+ </Container>
+ </Flex>
+ </Container>
+ {isOpen && (
+ <Container
+ width="100%"
+ position="absolute"
+ onClick={this._closeDropdown}
+ backgroundColor={ColorOption.white}
+ hasBoxShadow={true}
+ zIndex={zIndex.dropdownItems}
+ >
+ {_.map(items, (item, index) => (
+ <DropdownItem key={item.text} {...item} isLast={index === items.length - 1} />
+ ))}
+ </Container>
+ )}
+ </Container>
+ </React.Fragment>
+ );
+ }
+ private readonly _handleDropdownClick = (): void => {
+ if (_.isEmpty(this.props.items)) {
+ return;
+ }
+ this.setState({
+ isOpen: !this.state.isOpen,
+ });
+ };
+ private readonly _closeDropdown = (): void => {
+ this.setState({
+ isOpen: false,
+ });
+ };
+}
+
+export interface DropdownItemProps extends DropdownItemConfig {
+ text: string;
+ onClick?: () => void;
+ isLast: boolean;
+}
+
+export const DropdownItem: React.StatelessComponent<DropdownItemProps> = ({ text, onClick, isLast }) => (
+ <Container
+ onClick={onClick}
+ cursor="pointer"
+ darkenOnHover={true}
+ backgroundColor={ColorOption.white}
+ padding="0.8em"
+ borderTop="0"
+ border="1px solid"
+ borderRadius={isLast ? '0px 0px 4px 4px' : undefined}
+ width="100%"
+ borderColor={ColorOption.feintGrey}
+ >
+ <Text fontSize="14px" fontColor={ColorOption.darkGrey}>
+ {text}
+ </Text>
+ </Container>
+);
diff --git a/packages/instant/src/components/ui/flex.tsx b/packages/instant/src/components/ui/flex.tsx
index 327e91926..274c46b9e 100644
--- a/packages/instant/src/components/ui/flex.tsx
+++ b/packages/instant/src/components/ui/flex.tsx
@@ -1,5 +1,4 @@
-import * as React from 'react';
-
+import { MediaChoice, stylesForMedia } from '../../style/media';
import { ColorOption, styled } from '../../style/theme';
import { cssRuleIfExists } from '../../style/util';
@@ -8,23 +7,28 @@ export interface FlexProps {
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?: string;
+ width?: MediaChoice;
+ height?: MediaChoice;
backgroundColor?: ColorOption;
- className?: string;
+ inline?: boolean;
+ flexGrow?: number | string;
}
-const PlainFlex: React.StatelessComponent<FlexProps> = ({ children, className }) => (
- <div className={className}>{children}</div>
-);
-
-export const Flex = styled(PlainFlex)`
- display: flex;
- flex-direction: ${props => props.direction};
- flex-wrap: ${props => props.flexWrap};
- justify-content: ${props => props.justify};
- align-items: ${props => props.align};
- ${props => cssRuleIfExists(props, 'width')}
- background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')};
+export const Flex =
+ styled.div <
+ FlexProps >
+ `
+ && {
+ display: ${props => (props.inline ? 'inline-flex' : 'flex')};
+ flex-direction: ${props => props.direction};
+ flex-wrap: ${props => props.flexWrap};
+ ${props => cssRuleIfExists(props, 'flexGrow')}
+ justify-content: ${props => props.justify};
+ align-items: ${props => props.align};
+ background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')};
+ ${props => (props.width ? stylesForMedia('width', props.width) : '')}
+ ${props => (props.height ? stylesForMedia('height', props.height) : '')}
+ }
`;
Flex.defaultProps = {
diff --git a/packages/instant/src/components/ui/icon.tsx b/packages/instant/src/components/ui/icon.tsx
index 7373c3acd..a88fa87dd 100644
--- a/packages/instant/src/components/ui/icon.tsx
+++ b/packages/instant/src/components/ui/icon.tsx
@@ -1,19 +1,34 @@
+import * as _ from 'lodash';
import * as React from 'react';
-import { ColorOption } from '../../style/theme';
+import { ColorOption, styled, Theme, withTheme } from '../../style/theme';
type svgRule = 'evenodd' | 'nonzero' | 'inherit';
interface IconInfo {
viewBox: string;
+ path: string;
fillRule?: svgRule;
clipRule?: svgRule;
- path: 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',
@@ -28,31 +43,81 @@ const ICONS: IconInfoMapping = {
path:
'M17 34C26.3887 34 34 26.3888 34 17C34 7.61121 26.3887 0 17 0C7.61133 0 0 7.61121 0 17C0 26.3888 7.61133 34 17 34ZM25.7539 13.0977C26.2969 12.4718 26.2295 11.5244 25.6035 10.9817C24.9775 10.439 24.0303 10.5063 23.4878 11.1323L15.731 20.0771L12.3936 16.7438C11.8071 16.1583 10.8574 16.1589 10.272 16.7451C9.68652 17.3313 9.6875 18.281 10.2734 18.8665L14.75 23.3373L15.8887 24.4746L16.9434 23.2587L25.7539 13.0977Z',
},
+ chevron: {
+ viewBox: '0 0 12 7',
+ path: 'M11 1L6 6L1 1',
+ strokeOpacity: 0.5,
+ strokeWidth: 1.5,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ },
+ search: {
+ viewBox: '0 0 14 14',
+ fillRule: 'evenodd',
+ clipRule: 'evenodd',
+ path:
+ 'M8.39404 5.19727C8.39404 6.96289 6.96265 8.39453 5.19702 8.39453C3.4314 8.39453 2 6.96289 2 5.19727C2 3.43164 3.4314 2 5.19702 2C6.96265 2 8.39404 3.43164 8.39404 5.19727ZM8.09668 9.51074C7.26855 10.0684 6.27075 10.3945 5.19702 10.3945C2.3269 10.3945 0 8.06738 0 5.19727C0 2.32715 2.3269 0 5.19702 0C8.06738 0 10.394 2.32715 10.394 5.19727C10.394 6.27051 10.0686 7.26855 9.51074 8.09668L13.6997 12.2861L12.2854 13.7002L8.09668 9.51074Z',
+ },
};
export interface IconProps {
+ className?: string;
width: number;
- height: number;
- color: ColorOption;
+ height?: number;
+ color?: ColorOption;
+ stroke?: ColorOption;
icon: keyof IconInfoMapping;
+ onClick?: (event: React.MouseEvent<HTMLElement>) => void;
+ padding?: string;
+ theme: Theme;
}
-export const Icon: React.SFC<IconProps> = props => {
+const PlainIcon: React.StatelessComponent<IconProps> = props => {
const iconInfo = ICONS[props.icon];
-
+ const colorValue = _.isUndefined(props.color) ? undefined : props.theme[props.color];
+ const strokeValue = _.isUndefined(props.stroke) ? undefined : props.theme[props.stroke];
return (
- <svg
- width={props.width}
- height={props.height}
- viewBox={iconInfo.viewBox}
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d={iconInfo.path}
- fill={props.color}
- fillRule={iconInfo.fillRule || 'nonzero'}
- clipRule={iconInfo.clipRule || 'nonzero'}
- />
- </svg>
+ <div onClick={props.onClick} className={props.className}>
+ <svg
+ width={props.width}
+ height={props.height}
+ viewBox={iconInfo.viewBox}
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d={iconInfo.path}
+ fill={colorValue}
+ fillRule={iconInfo.fillRule || 'nonzero'}
+ clipRule={iconInfo.clipRule || 'nonzero'}
+ stroke={strokeValue}
+ strokeOpacity={iconInfo.strokeOpacity}
+ strokeWidth={iconInfo.strokeWidth}
+ strokeLinecap={iconInfo.strokeLinecap}
+ strokeLinejoin={iconInfo.strokeLinejoin}
+ />
+ </svg>
+ </div>
);
};
+
+export const Icon = withTheme(styled(PlainIcon)`
+ && {
+ display: inline-block;
+ ${props => (!_.isUndefined(props.onClick) ? 'cursor: pointer' : '')};
+ transition: opacity 0.5s ease;
+ padding: ${props => props.padding};
+ opacity: ${props => (!_.isUndefined(props.onClick) ? 0.7 : 1)};
+ &:hover {
+ opacity: 1;
+ }
+ &:active {
+ opacity: 1;
+ }
+ }
+`);
+
+Icon.defaultProps = {
+ padding: '0em 0em',
+};
+
+Icon.displayName = 'Icon';
diff --git a/packages/instant/src/components/ui/index.ts b/packages/instant/src/components/ui/index.ts
deleted file mode 100644
index bf5f6c700..000000000
--- a/packages/instant/src/components/ui/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { Text, Title } from './text';
-export { Button } from './button';
-export { Flex } from './flex';
-export { Container } from './container';
-export { Input } from './input';
diff --git a/packages/instant/src/components/ui/input.tsx b/packages/instant/src/components/ui/input.tsx
index f8c6b6ef6..2fb408db4 100644
--- a/packages/instant/src/components/ui/input.tsx
+++ b/packages/instant/src/components/ui/input.tsx
@@ -12,22 +12,24 @@ export interface InputProps {
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
-const PlainInput: React.StatelessComponent<InputProps> = ({ value, className, placeholder, onChange }) => (
- <input className={className} value={value} onChange={onChange} placeholder={placeholder} />
-);
-
-export const Input = styled(PlainInput)`
- 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 {
+export const Input =
+ styled.input <
+ InputProps >
+ `
+ && {
+ all: initial;
+ font-size: ${props => props.fontSize};
+ width: ${props => props.width};
+ padding: 0.1em 0em;
+ font-family: 'Inter UI';
color: ${props => props.theme[props.fontColor || 'white']};
- opacity: 0.5;
+ background: transparent;
+ outline: none;
+ border: none;
+ &::placeholder {
+ color: ${props => props.theme[props.fontColor || 'white']};
+ opacity: 0.5;
+ }
}
`;
diff --git a/packages/instant/src/components/ui/overlay.tsx b/packages/instant/src/components/ui/overlay.tsx
new file mode 100644
index 000000000..f67d6fb2f
--- /dev/null
+++ b/packages/instant/src/components/ui/overlay.tsx
@@ -0,0 +1,39 @@
+import * as _ from 'lodash';
+
+import { generateMediaWrapper, ScreenWidths } from '../../style/media';
+import { generateOverlayBlack, styled } from '../../style/theme';
+import { zIndex } from '../../style/z_index';
+
+export interface OverlayProps {
+ zIndex?: number;
+ backgroundColor?: string;
+ width?: string;
+ height?: string;
+ showMaxWidth?: ScreenWidths;
+}
+
+export const Overlay =
+ styled.div <
+ OverlayProps >
+ `
+ && {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: ${props => props.zIndex}
+ background-color: ${props => props.backgroundColor};
+ ${props => props.width && `width: ${props.width};`}
+ ${props => props.height && `height: ${props.height};`}
+ display: ${props => (props.showMaxWidth ? 'none' : 'block')};
+ ${props => props.showMaxWidth && generateMediaWrapper(props.showMaxWidth)`display: block;`}
+ }
+`;
+
+Overlay.defaultProps = {
+ zIndex: zIndex.overlayDefault,
+ backgroundColor: generateOverlayBlack(0.6),
+};
+
+Overlay.displayName = 'Overlay';
diff --git a/packages/instant/src/components/ui/text.tsx b/packages/instant/src/components/ui/text.tsx
index 9fb8ea26f..4fe429d25 100644
--- a/packages/instant/src/components/ui/text.tsx
+++ b/packages/instant/src/components/ui/text.tsx
@@ -18,40 +18,36 @@ export interface TextProps {
fontWeight?: number | string;
textDecorationLine?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
- hoverColor?: string;
noWrap?: boolean;
display?: string;
}
-const PlainText: React.StatelessComponent<TextProps> = ({ children, className, onClick }) => (
- <div className={className} onClick={onClick}>
- {children}
- </div>
-);
-
const darkenOnHoverAmount = 0.3;
-export const Text = styled(PlainText)`
- 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: ${props.hoverColor || darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}`
- : ''};
+export const Text =
+ styled.div <
+ TextProps >
+ `
+ && {
+ font-family: 'Inter UI', sans-serif;
+ font-style: ${props => props.fontStyle};
+ font-weight: ${props => props.fontWeight};
+ font-size: ${props => props.fontSize};
+ opacity: ${props => props.opacity};
+ text-decoration-line: ${props => props.textDecorationLine};
+ ${props => (props.lineHeight ? `line-height: ${props.lineHeight}` : '')};
+ ${props => (props.center ? 'text-align: center' : '')};
+ color: ${props => props.fontColor && props.theme[props.fontColor]};
+ ${props => (props.minHeight ? `min-height: ${props.minHeight}` : '')};
+ ${props => (props.onClick ? 'cursor: pointer' : '')};
+ transition: color 0.5s ease;
+ ${props => (props.noWrap ? 'white-space: nowrap' : '')};
+ ${props => (props.display ? `display: ${props.display}` : '')};
+ ${props => (props.letterSpacing ? `letter-spacing: ${props.letterSpacing}` : '')};
+ ${props => (props.textTransform ? `text-transform: ${props.textTransform}` : '')};
+ &:hover {
+ ${props =>
+ props.onClick ? `color: ${darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` : ''};
+ }
}
`;
@@ -67,14 +63,3 @@ Text.defaultProps = {
};
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/view_transaction_button.tsx b/packages/instant/src/components/view_transaction_button.tsx
deleted file mode 100644
index 7aa44e657..000000000
--- a/packages/instant/src/components/view_transaction_button.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as React from 'react';
-
-import { SecondaryButton } from './secondary_button';
-
-export interface ViewTransactionButtonProps {
- onClick: () => void;
-}
-
-export const ViewTransactionButton: React.StatelessComponent<ViewTransactionButtonProps> = props => {
- return <SecondaryButton onClick={props.onClick}>View Transaction</SecondaryButton>;
-};
diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx
index ffa5a8250..b945f9908 100644
--- a/packages/instant/src/components/zero_ex_instant.tsx
+++ b/packages/instant/src/components/zero_ex_instant.tsx
@@ -1,75 +1,18 @@
-import { AssetBuyer } from '@0x/asset-buyer';
-import { ObjectMap } from '@0x/types';
import * as React from 'react';
-import { Provider } from 'react-redux';
-import { SelectedAssetThemeProvider } from '../containers/selected_asset_theme_provider';
-import { asyncData } from '../redux/async_data';
-import { INITIAL_STATE, State } from '../redux/reducer';
-import { store, Store } from '../redux/store';
-import { fonts } from '../style/fonts';
-import { AssetMetaData, Network } from '../types';
-import { assetUtils } from '../util/asset';
-import { getProvider } from '../util/provider';
+import { INJECTED_DIV_CLASS } from '../constants';
import { ZeroExInstantContainer } from './zero_ex_instant_container';
-
-fonts.include();
-
-export type ZeroExInstantProps = ZeroExInstantRequiredProps & Partial<ZeroExInstantOptionalProps>;
-
-export interface ZeroExInstantRequiredProps {
- // TODO: Change API when we allow the selection of different assetDatas
- assetData: string;
- // TODO: Allow for a function that returns orders
- liquiditySource: string;
-}
-
-export interface ZeroExInstantOptionalProps {
- additionalAssetMetaDataMap: ObjectMap<AssetMetaData>;
- network: Network;
-}
-
-export class ZeroExInstant extends React.Component<ZeroExInstantProps> {
- private readonly _store: Store;
- private static _mergeInitialStateWithProps(props: ZeroExInstantProps, state: State = INITIAL_STATE): State {
- // Create merged object such that properties in props override default settings
- const optionalPropsWithDefaults: ZeroExInstantOptionalProps = {
- additionalAssetMetaDataMap: props.additionalAssetMetaDataMap || {},
- network: props.network || state.network,
- };
- const { network } = optionalPropsWithDefaults;
- // TODO: Provider needs to not be hard-coded to injected web3.
- const assetBuyer = AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl(getProvider(), props.liquiditySource, {
- networkId: network,
- });
- const completeAssetMetaDataMap = {
- ...props.additionalAssetMetaDataMap,
- ...state.assetMetaDataMap,
- };
- const storeStateFromProps: State = {
- ...state,
- assetBuyer,
- network,
- selectedAsset: assetUtils.createAssetFromAssetData(props.assetData, completeAssetMetaDataMap, network),
- assetMetaDataMap: completeAssetMetaDataMap,
- };
- return storeStateFromProps;
- }
- constructor(props: ZeroExInstantProps) {
- super(props);
- this._store = store.create(ZeroExInstant._mergeInitialStateWithProps(this.props, INITIAL_STATE));
- // tslint:disable-next-line:no-floating-promises
- asyncData.fetchAndDispatchToStore(this._store);
- }
-
- public render(): React.ReactNode {
- return (
- <Provider store={this._store}>
- <SelectedAssetThemeProvider>
- <ZeroExInstantContainer />
- </SelectedAssetThemeProvider>
- </Provider>
- );
- }
-}
+import { ZeroExInstantProvider, ZeroExInstantProviderProps } from './zero_ex_instant_provider';
+
+export type ZeroExInstantProps = ZeroExInstantProviderProps;
+
+export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = props => {
+ return (
+ <div className={INJECTED_DIV_CLASS}>
+ <ZeroExInstantProvider {...props}>
+ <ZeroExInstantContainer />
+ </ZeroExInstantProvider>
+ </div>
+ );
+};
diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx
index 1d17ed12a..5748e064e 100644
--- a/packages/instant/src/components/zero_ex_instant_container.tsx
+++ b/packages/instant/src/components/zero_ex_instant_container.tsx
@@ -1,35 +1,78 @@
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 { SelectedAssetBuyOrderStateButton } from '../containers/selected_asset_buy_order_state_button';
+import { SelectedAssetBuyOrderProgress } from '../containers/selected_asset_buy_order_progress';
+import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_buy_order_state_buttons';
import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading';
-
import { ColorOption } from '../style/theme';
+import { zIndex } from '../style/z_index';
-import { Container, Flex } from './ui';
+import { SlideAnimationState } from './animations/slide_animation';
+import { CSSReset } from './css_reset';
+import { SlidingPanel } from './sliding_panel';
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
export interface ZeroExInstantContainerProps {}
+export interface ZeroExInstantContainerState {
+ tokenSelectionPanelAnimationState: SlideAnimationState;
+}
-export const ZeroExInstantContainer: React.StatelessComponent<ZeroExInstantContainerProps> = props => (
- <Container width="350px">
- <Container zIndex={1} position="relative">
- <LatestError />
- </Container>
- <Container
- zIndex={2}
- position="relative"
- backgroundColor={ColorOption.white}
- borderRadius="3px"
- hasBoxShadow={true}
- >
- <Flex direction="column" justify="flex-start">
- <SelectedAssetInstantHeading />
- <LatestBuyQuoteOrderDetails />
- <Container padding="20px" width="100%">
- <SelectedAssetBuyOrderStateButton />
+export class ZeroExInstantContainer extends React.Component<ZeroExInstantContainerProps, ZeroExInstantContainerState> {
+ public state = {
+ tokenSelectionPanelAnimationState: 'none' as SlideAnimationState,
+ };
+ public render(): React.ReactNode {
+ return (
+ <React.Fragment>
+ <CSSReset />
+ <Container
+ width={{ default: '350px', sm: '100%' }}
+ height={{ default: 'auto', sm: '100%' }}
+ position="relative"
+ >
+ <Container position="relative">
+ <LatestError />
+ </Container>
+ <Container
+ zIndex={zIndex.mainContainer}
+ position="relative"
+ backgroundColor={ColorOption.white}
+ borderRadius="3px"
+ hasBoxShadow={true}
+ overflow="hidden"
+ height="100%"
+ >
+ <Flex direction="column" justify="flex-start" height="100%">
+ <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>
- </Flex>
- </Container>
- </Container>
-);
+ </React.Fragment>
+ );
+ }
+ private readonly _handleSymbolClick = (): void => {
+ this.setState({
+ tokenSelectionPanelAnimationState: 'slidIn',
+ });
+ };
+ private readonly _handlePanelClose = (): void => {
+ this.setState({
+ tokenSelectionPanelAnimationState: 'slidOut',
+ });
+ };
+}
diff --git a/packages/instant/src/components/zero_ex_instant_overlay.tsx b/packages/instant/src/components/zero_ex_instant_overlay.tsx
new file mode 100644
index 000000000..10438ab7a
--- /dev/null
+++ b/packages/instant/src/components/zero_ex_instant_overlay.tsx
@@ -0,0 +1,40 @@
+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 { 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 zIndex={zIndex}>
+ <Flex height="100vh">
+ <Container position="absolute" top="0px" right="0px" display={{ default: 'initial', sm: 'none' }}>
+ <Icon
+ height={18}
+ width={18}
+ color={ColorOption.white}
+ icon="closeX"
+ onClick={onClose}
+ padding="2em 2em"
+ />
+ </Container>
+ <Container width={{ default: 'auto', sm: '100%' }} height={{ default: 'auto', sm: '100%' }}>
+ <ZeroExInstantContainer />
+ </Container>
+ </Flex>
+ </Overlay>
+ </ZeroExInstantProvider>
+ );
+};
diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx
new file mode 100644
index 000000000..411f118cc
--- /dev/null
+++ b/packages/instant/src/components/zero_ex_instant_provider.tsx
@@ -0,0 +1,149 @@
+import { ObjectMap } from '@0x/types';
+import { BigNumber } from '@0x/utils';
+import { Provider } from 'ethereum-types';
+import * as _ from 'lodash';
+import * as React from 'react';
+import { Provider as ReduxProvider } from 'react-redux';
+
+import { ACCOUNT_UPDATE_INTERVAL_TIME_MS, BUY_QUOTE_UPDATE_INTERVAL_TIME_MS } from '../constants';
+import { SelectedAssetThemeProvider } from '../containers/selected_asset_theme_provider';
+import { asyncData } from '../redux/async_data';
+import { DEFAULT_STATE, DefaultState, State } from '../redux/reducer';
+import { store, Store } from '../redux/store';
+import { fonts } from '../style/fonts';
+import { AccountState, AffiliateInfo, AssetMetaData, Network, OrderSource } from '../types';
+import { assetUtils } from '../util/asset';
+import { errorFlasher } from '../util/error_flasher';
+import { gasPriceEstimator } from '../util/gas_price_estimator';
+import { Heartbeater } from '../util/heartbeater';
+import { generateAccountHeartbeater, generateBuyQuoteHeartbeater } from '../util/heartbeater_factory';
+import { providerStateFactory } from '../util/provider_state_factory';
+
+fonts.include();
+
+export type ZeroExInstantProviderProps = ZeroExInstantProviderRequiredProps &
+ Partial<ZeroExInstantProviderOptionalProps>;
+
+export interface ZeroExInstantProviderRequiredProps {
+ orderSource: OrderSource;
+}
+
+export interface ZeroExInstantProviderOptionalProps {
+ provider: Provider;
+ availableAssetDatas: string[];
+ defaultAssetBuyAmount: number;
+ defaultSelectedAssetData: string;
+ additionalAssetMetaDataMap: ObjectMap<AssetMetaData>;
+ networkId: Network;
+ affiliateInfo: AffiliateInfo;
+}
+
+export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> {
+ private readonly _store: Store;
+ private _accountUpdateHeartbeat?: Heartbeater;
+ private _buyQuoteHeartbeat?: Heartbeater;
+
+ // TODO(fragosti): Write tests for this beast once we inject a provider.
+ private static _mergeDefaultStateWithProps(
+ props: ZeroExInstantProviderProps,
+ defaultState: DefaultState = DEFAULT_STATE,
+ ): State {
+ // use the networkId passed in with the props, otherwise default to that of the default state (1, mainnet)
+ const networkId = props.networkId || defaultState.network;
+ // construct the ProviderState
+ const providerState = providerStateFactory.getInitialProviderState(
+ props.orderSource,
+ networkId,
+ props.provider,
+ );
+ // merge the additional additionalAssetMetaDataMap with our default map
+ const completeAssetMetaDataMap = {
+ ...props.additionalAssetMetaDataMap,
+ ...defaultState.assetMetaDataMap,
+ };
+ // construct the final state
+ const storeStateFromProps: State = {
+ ...defaultState,
+ providerState,
+ network: networkId,
+ selectedAsset: _.isUndefined(props.defaultSelectedAssetData)
+ ? undefined
+ : assetUtils.createAssetFromAssetDataOrThrow(
+ props.defaultSelectedAssetData,
+ completeAssetMetaDataMap,
+ networkId,
+ ),
+ 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);
+ }
+ if (state.providerState.account.state !== AccountState.None) {
+ this._accountUpdateHeartbeat = generateAccountHeartbeater({
+ store: this._store,
+ shouldPerformImmediatelyOnStart: true,
+ });
+ this._accountUpdateHeartbeat.start(ACCOUNT_UPDATE_INTERVAL_TIME_MS);
+ }
+
+ this._buyQuoteHeartbeat = generateBuyQuoteHeartbeater({
+ store: this._store,
+ shouldPerformImmediatelyOnStart: false,
+ });
+ this._buyQuoteHeartbeat.start(BUY_QUOTE_UPDATE_INTERVAL_TIME_MS);
+ // tslint:disable-next-line:no-floating-promises
+ asyncData.fetchCurrentBuyQuoteAndDispatchToStore({ store: this._store, shouldSetPending: true });
+ // warm up the gas price estimator cache just in case we can't
+ // grab the gas price estimate when submitting the transaction
+ // tslint:disable-next-line:no-floating-promises
+ gasPriceEstimator.getGasInfoAsync();
+ // tslint:disable-next-line:no-floating-promises
+ this._flashErrorIfWrongNetwork();
+ }
+ public componentWillUnmount(): void {
+ if (this._accountUpdateHeartbeat) {
+ this._accountUpdateHeartbeat.stop();
+ }
+ if (this._buyQuoteHeartbeat) {
+ this._buyQuoteHeartbeat.stop();
+ }
+ }
+ public render(): React.ReactNode {
+ return (
+ <ReduxProvider store={this._store}>
+ <SelectedAssetThemeProvider>{this.props.children}</SelectedAssetThemeProvider>
+ </ReduxProvider>
+ );
+ }
+ private readonly _flashErrorIfWrongNetwork = async (): Promise<void> => {
+ const msToShowError = 30000; // 30 seconds
+ const state = this._store.getState();
+ const network = state.network;
+ const web3Wrapper = state.providerState.web3Wrapper;
+ const networkOfProvider = await web3Wrapper.getNetworkIdAsync();
+ if (network !== networkOfProvider) {
+ const errorMessage = `Wrong network detected. Try switching to ${Network[network]}.`;
+ errorFlasher.flashNewErrorMessage(this._store.dispatch, errorMessage, msToShowError);
+ }
+ };
+}
diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts
index 48d0d4aa2..110a8248a 100644
--- a/packages/instant/src/constants.ts
+++ b/packages/instant/src/constants.ts
@@ -1,5 +1,35 @@
import { BigNumber } from '@0x/utils';
+
+import { AccountNotReady, AccountState, Network } from './types';
+
export const BIG_NUMBER_ZERO = new BigNumber(0);
-export const ethDecimals = 18;
+export const ETH_DECIMALS = 18;
export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer';
+export const INJECTED_DIV_CLASS = 'zeroExInstantResetRoot';
+export const INJECTED_DIV_ID = 'zeroExInstant';
export const WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX = 'Transaction failed';
+export const GWEI_IN_WEI = new BigNumber(1000000000);
+export const ONE_SECOND_MS = 1000;
+export const ONE_MINUTE_MS = ONE_SECOND_MS * 60;
+export const ACCOUNT_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 5;
+export const BUY_QUOTE_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 15;
+export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6);
+export const DEFAULT_ESTIMATED_TRANSACTION_TIME_MS = ONE_MINUTE_MS * 2;
+export const ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info';
+export const COINBASE_API_BASE_URL = 'https://api.coinbase.com/v2';
+export const PROGRESS_STALL_AT_WIDTH = '95%';
+export const PROGRESS_FINISH_ANIMATION_TIME_MS = 200;
+export const ETHEREUM_NODE_URL_BY_NETWORK = {
+ [Network.Mainnet]: 'https://mainnet.infura.io/',
+ [Network.Kovan]: 'https://kovan.infura.io/',
+};
+export const BLOCK_POLLING_INTERVAL_MS = 10000; // 10s
+export const NO_ACCOUNT: AccountNotReady = {
+ state: AccountState.None,
+};
+export const LOADING_ACCOUNT: AccountNotReady = {
+ state: AccountState.Loading,
+};
+export const LOCKED_ACCOUNT: AccountNotReady = {
+ state: AccountState.Locked,
+};
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
index 092aaaf20..2b59ed3ae 100644
--- a/packages/instant/src/containers/latest_buy_quote_order_details.ts
+++ b/packages/instant/src/containers/latest_buy_quote_order_details.ts
@@ -22,7 +22,7 @@ const mapStateToProps = (state: State, _ownProps: LatestBuyQuoteOrderDetailsProp
// use the worst case quote info
buyQuoteInfo: oc(state).latestBuyQuote.worstCaseQuoteInfo(),
ethUsdPrice: state.ethUsdPrice,
- isLoading: state.quoteRequestState === AsyncProcessState.PENDING,
+ isLoading: state.quoteRequestState === AsyncProcessState.Pending,
});
export const LatestBuyQuoteOrderDetails: React.ComponentClass<LatestBuyQuoteOrderDetailsProps> = connect(
diff --git a/packages/instant/src/containers/latest_error.tsx b/packages/instant/src/containers/latest_error.tsx
index b75ec00aa..c0da181f1 100644
--- a/packages/instant/src/containers/latest_error.tsx
+++ b/packages/instant/src/containers/latest_error.tsx
@@ -1,36 +1,60 @@
import * as React from 'react';
import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { SlideAnimationState } from '../components/animations/slide_animation';
import { SlidingError } from '../components/sliding_error';
+import { Overlay } from '../components/ui/overlay';
+import { Action } from '../redux/actions';
import { State } from '../redux/reducer';
-import { Asset, DisplayStatus } from '../types';
-import { errorUtil } from '../util/error';
+import { ScreenWidths } from '../style/media';
+import { generateOverlayBlack } from '../style/theme';
+import { zIndex } from '../style/z_index';
+import { Asset, DisplayStatus, Omit } from '../types';
+import { errorFlasher } from '../util/error_flasher';
export interface LatestErrorComponentProps {
asset?: Asset;
- latestError?: any;
- slidingDirection: 'down' | 'up';
+ latestErrorMessage?: string;
+ animationState: SlideAnimationState;
+ shouldRenderOverlay: boolean;
+ onOverlayClick: () => void;
}
export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => {
- if (!props.latestError) {
+ if (!props.latestErrorMessage) {
return <div />;
}
- const { icon, message } = errorUtil.errorDescription(props.latestError, props.asset);
- return <SlidingError direction={props.slidingDirection} icon={icon} message={message} />;
+ return (
+ <React.Fragment>
+ <SlidingError animationState={props.animationState} icon="😢" message={props.latestErrorMessage} />
+ {props.shouldRenderOverlay && (
+ <Overlay
+ onClick={props.onOverlayClick}
+ zIndex={zIndex.containerOverlay}
+ showMaxWidth={ScreenWidths.Sm}
+ backgroundColor={generateOverlayBlack(0.4)}
+ />
+ )}
+ </React.Fragment>
+ );
};
-interface ConnectedState {
- asset?: Asset;
- latestError?: any;
- slidingDirection: 'down' | 'up';
-}
export interface LatestErrorProps {}
+interface ConnectedState extends Omit<LatestErrorComponentProps, 'onOverlayClick'> {}
const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({
asset: state.selectedAsset,
- latestError: state.latestError,
- slidingDirection: state.latestErrorDisplay === DisplayStatus.Present ? 'up' : 'down',
+ latestErrorMessage: state.latestErrorMessage,
+ animationState: state.latestErrorDisplayStatus === DisplayStatus.Present ? 'slidIn' : 'slidOut',
+ shouldRenderOverlay: state.latestErrorDisplayStatus === DisplayStatus.Present,
+});
+
+type ConnectedDispatch = Pick<LatestErrorComponentProps, 'onOverlayClick'>;
+const mapDispatchToProps = (dispatch: Dispatch<Action>, _ownProps: LatestErrorProps): ConnectedDispatch => ({
+ onOverlayClick: () => {
+ errorFlasher.clearError(dispatch);
+ },
});
-export const LatestError = connect(mapStateToProps)(LatestErrorComponent);
+export const LatestError = connect(mapStateToProps, mapDispatchToProps)(LatestErrorComponent);
diff --git a/packages/instant/src/containers/selected_asset_amount_input.ts b/packages/instant/src/containers/selected_asset_amount_input.ts
deleted file mode 100644
index e9dbc61ce..000000000
--- a/packages/instant/src/containers/selected_asset_amount_input.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { AssetBuyer, 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 { Action, actions } from '../redux/actions';
-import { State } from '../redux/reducer';
-import { ColorOption } from '../style/theme';
-import { ERC20Asset, OrderProcessState } from '../types';
-import { errorUtil } from '../util/error';
-
-import { AssetAmountInput } from '../components/asset_amount_input';
-
-export interface SelectedAssetAmountInputProps {
- fontColor?: ColorOption;
- fontSize?: string;
-}
-
-interface ConnectedState {
- assetBuyer?: AssetBuyer;
- value?: BigNumber;
- asset?: ERC20Asset;
-}
-
-interface ConnectedDispatch {
- updateBuyQuote: (assetBuyer?: AssetBuyer, value?: BigNumber, asset?: ERC20Asset) => void;
-}
-
-interface ConnectedProps {
- value?: BigNumber;
- asset?: ERC20Asset;
- onChange: (value?: BigNumber, asset?: ERC20Asset) => void;
-}
-
-type FinalProps = ConnectedProps & SelectedAssetAmountInputProps;
-
-const mapStateToProps = (state: State, _ownProps: SelectedAssetAmountInputProps): ConnectedState => {
- const selectedAsset = state.selectedAsset;
- if (_.isUndefined(selectedAsset) || selectedAsset.metaData.assetProxyId !== AssetProxyId.ERC20) {
- return {
- value: state.selectedAssetAmount,
- };
- }
- return {
- assetBuyer: state.assetBuyer,
- value: state.selectedAssetAmount,
- asset: selectedAsset as ERC20Asset,
- };
-};
-
-const updateBuyQuoteAsync = async (
- assetBuyer: AssetBuyer,
- dispatch: Dispatch<Action>,
- asset: ERC20Asset,
- assetAmount: BigNumber,
-): Promise<void> => {
- // get a new buy quote.
- const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals);
-
- // mark quote as pending
- dispatch(actions.setQuoteRequestStatePending());
-
- let newBuyQuote: BuyQuote | undefined;
- try {
- newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue);
- } catch (error) {
- dispatch(actions.setQuoteRequestStateFailure());
- errorUtil.errorFlasher.flashNewError(dispatch, error);
- return;
- }
- // We have a successful new buy quote
- errorUtil.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: SelectedAssetAmountInputProps,
-): ConnectedDispatch => ({
- updateBuyQuote: (assetBuyer, value, asset) => {
- // Update the input
- dispatch(actions.updateSelectedAssetAmount(value));
- // invalidate the last buy quote.
- dispatch(actions.updateLatestBuyQuote(undefined));
- // reset our buy state
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.NONE }));
-
- if (!_.isUndefined(value) && !_.isUndefined(asset) && !_.isUndefined(assetBuyer)) {
- // 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);
- }
- },
-});
-
-const mergeProps = (
- connectedState: ConnectedState,
- connectedDispatch: ConnectedDispatch,
- ownProps: SelectedAssetAmountInputProps,
-): FinalProps => {
- return {
- ...ownProps,
- asset: connectedState.asset,
- value: connectedState.value,
- onChange: (value, asset) => {
- connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset);
- },
- };
-};
-
-export const SelectedAssetAmountInput: React.ComponentClass<SelectedAssetAmountInputProps> = connect(
- mapStateToProps,
- mapDispatchToProps,
- mergeProps,
-)(AssetAmountInput);
diff --git a/packages/instant/src/containers/selected_asset_buy_button.ts b/packages/instant/src/containers/selected_asset_buy_button.ts
deleted file mode 100644
index adcbd61bc..000000000
--- a/packages/instant/src/containers/selected_asset_buy_button.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { AssetBuyer, BuyQuote } from '@0x/asset-buyer';
-import * as _ from 'lodash';
-import * as React from 'react';
-import { connect } from 'react-redux';
-import { Dispatch } from 'redux';
-
-import { Action, actions } from '../redux/actions';
-import { State } from '../redux/reducer';
-import { OrderProcessState, OrderState } from '../types';
-
-import { BuyButton } from '../components/buy_button';
-
-export interface SelectedAssetBuyButtonProps {}
-
-interface ConnectedState {
- assetBuyer?: AssetBuyer;
- buyQuote?: BuyQuote;
-}
-
-interface ConnectedDispatch {
- onAwaitingSignature: (buyQuote: BuyQuote) => void;
- onSignatureDenied: (buyQuote: BuyQuote, error: Error) => void;
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void;
- onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
- onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
-}
-
-const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyButtonProps): ConnectedState => ({
- assetBuyer: state.assetBuyer,
- buyQuote: state.latestBuyQuote,
-});
-
-const mapDispatchToProps = (dispatch: Dispatch<Action>, ownProps: SelectedAssetBuyButtonProps): ConnectedDispatch => ({
- onAwaitingSignature: (buyQuote: BuyQuote) => {
- const newOrderState: OrderState = { processState: OrderProcessState.AWAITING_SIGNATURE };
- dispatch(actions.updateBuyOrderState(newOrderState));
- },
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => {
- const newOrderState: OrderState = { processState: OrderProcessState.PROCESSING, txHash };
- dispatch(actions.updateBuyOrderState(newOrderState));
- },
- onBuySuccess: (buyQuote: BuyQuote, txHash: string) =>
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.SUCCESS, txHash })),
- onBuyFailure: (buyQuote: BuyQuote, txHash: string) =>
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.FAILURE, txHash })),
- onSignatureDenied: (buyQuote, error) => {
- dispatch(actions.resetAmount());
- dispatch(actions.setError(error));
- },
-});
-
-export const SelectedAssetBuyButton: React.ComponentClass<SelectedAssetBuyButtonProps> = connect(
- mapStateToProps,
- mapDispatchToProps,
-)(BuyButton);
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_button.tsx b/packages/instant/src/containers/selected_asset_buy_order_state_button.tsx
deleted file mode 100644
index 7faa79912..000000000
--- a/packages/instant/src/containers/selected_asset_buy_order_state_button.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import * as _ from 'lodash';
-import * as React from 'react';
-import { connect } from 'react-redux';
-
-import { State } from '../redux/reducer';
-import { OrderProcessState } from '../types';
-
-import { BuyOrderStateButton } from '../components/buy_order_state_button';
-
-interface ConnectedState {
- buyOrderProcessingState: OrderProcessState;
-}
-export interface SelectedAssetButtonProps {}
-const mapStateToProps = (state: State, _ownProps: SelectedAssetButtonProps): ConnectedState => ({
- buyOrderProcessingState: state.buyOrderState.processState,
-});
-
-export const SelectedAssetBuyOrderStateButton: React.ComponentClass<SelectedAssetButtonProps> = connect(
- mapStateToProps,
-)(BuyOrderStateButton);
diff --git a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts
new file mode 100644
index 000000000..610335243
--- /dev/null
+++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts
@@ -0,0 +1,104 @@
+import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
+import { BigNumber } from '@0x/utils';
+import { Web3Wrapper } from '@0x/web3-wrapper';
+import * as _ from 'lodash';
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+
+import { BuyOrderStateButtons } from '../components/buy_order_state_buttons';
+import { Action, actions } from '../redux/actions';
+import { State } from '../redux/reducer';
+import { AccountState, AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types';
+import { errorFlasher } from '../util/error_flasher';
+import { etherscanUtil } from '../util/etherscan';
+
+interface ConnectedState {
+ accountAddress?: string;
+ accountEthBalanceInWei?: BigNumber;
+ buyQuote?: BuyQuote;
+ buyOrderProcessingState: OrderProcessState;
+ assetBuyer: AssetBuyer;
+ web3Wrapper: Web3Wrapper;
+ affiliateInfo?: AffiliateInfo;
+ onViewTransaction: () => void;
+}
+
+interface ConnectedDispatch {
+ onValidationPending: (buyQuote: BuyQuote) => void;
+ onSignatureDenied: (buyQuote: BuyQuote) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
+ onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
+ onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
+ onRetry: () => void;
+ onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void;
+}
+export interface SelectedAssetBuyOrderStateButtons {}
+const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButtons): ConnectedState => {
+ const assetBuyer = state.providerState.assetBuyer;
+ const web3Wrapper = state.providerState.web3Wrapper;
+ const account = state.providerState.account;
+ const accountAddress = account.state === AccountState.Ready ? account.address : undefined;
+ const accountEthBalanceInWei = account.state === AccountState.Ready ? account.ethBalanceInWei : undefined;
+ return {
+ accountAddress,
+ accountEthBalanceInWei,
+ buyOrderProcessingState: state.buyOrderState.processState,
+ assetBuyer,
+ web3Wrapper,
+ buyQuote: state.latestBuyQuote,
+ affiliateInfo: state.affiliateInfo,
+ onViewTransaction: () => {
+ if (
+ state.buyOrderState.processState === OrderProcessState.Processing ||
+ state.buyOrderState.processState === OrderProcessState.Success ||
+ state.buyOrderState.processState === OrderProcessState.Failure
+ ) {
+ const etherscanUrl = etherscanUtil.getEtherScanTxnAddressIfExists(
+ state.buyOrderState.txHash,
+ assetBuyer.networkId,
+ );
+ if (etherscanUrl) {
+ window.open(etherscanUrl, '_blank');
+ return;
+ }
+ }
+ },
+ };
+};
+
+const mapDispatchToProps = (
+ dispatch: Dispatch<Action>,
+ ownProps: SelectedAssetBuyOrderStateButtons,
+): ConnectedDispatch => ({
+ onValidationPending: (buyQuote: BuyQuote) => {
+ dispatch(actions.setBuyOrderStateValidating());
+ },
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => {
+ dispatch(actions.setBuyOrderStateProcessing(txHash, startTimeUnix, expectedEndTimeUnix));
+ },
+ onBuySuccess: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateSuccess(txHash)),
+ onBuyFailure: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateFailure(txHash)),
+ onSignatureDenied: () => {
+ dispatch(actions.resetAmount());
+ const errorMessage = 'You denied this transaction';
+ errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
+ },
+ onValidationFail: (buyQuote, error) => {
+ dispatch(actions.setBuyOrderStateNone());
+ if (error === ZeroExInstantError.InsufficientETH) {
+ const errorMessage = "You don't have enough ETH";
+ errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
+ } else {
+ errorFlasher.flashNewErrorMessage(dispatch);
+ }
+ },
+ onRetry: () => {
+ dispatch(actions.resetAmount());
+ },
+});
+
+export const SelectedAssetBuyOrderStateButtons: React.ComponentClass<SelectedAssetBuyOrderStateButtons> = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(BuyOrderStateButtons);
diff --git a/packages/instant/src/containers/selected_asset_instant_heading.ts b/packages/instant/src/containers/selected_asset_instant_heading.ts
index 6b2a29b07..a407279e6 100644
--- a/packages/instant/src/containers/selected_asset_instant_heading.ts
+++ b/packages/instant/src/containers/selected_asset_instant_heading.ts
@@ -5,11 +5,13 @@ import { connect } from 'react-redux';
import { oc } from 'ts-optchain';
import { State } from '../redux/reducer';
-import { AsyncProcessState, OrderState } from '../types';
+import { AsyncProcessState, ERC20Asset, OrderState } from '../types';
import { InstantHeading } from '../components/instant_heading';
-export interface InstantHeadingProps {}
+export interface InstantHeadingProps {
+ onSelectAssetClick?: (asset?: ERC20Asset) => void;
+}
interface ConnectedState {
selectedAssetAmount?: BigNumber;
diff --git a/packages/instant/src/containers/selected_asset_retry_button.tsx b/packages/instant/src/containers/selected_asset_retry_button.tsx
deleted file mode 100644
index b2b140be6..000000000
--- a/packages/instant/src/containers/selected_asset_retry_button.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as _ from 'lodash';
-import * as React from 'react';
-import { connect } from 'react-redux';
-import { Dispatch } from 'redux';
-
-import { Action, actions } from '../redux/actions';
-
-import { RetryButton } from '../components/retry_button';
-
-export interface SelectedAssetRetryButtonProps {}
-
-interface ConnectedDispatch {
- onClick: () => void;
-}
-
-const mapDispatchToProps = (
- dispatch: Dispatch<Action>,
- _ownProps: SelectedAssetRetryButtonProps,
-): ConnectedDispatch => ({
- onClick: () => dispatch(actions.resetAmount()),
-});
-
-export const SelectedAssetRetryButton: React.ComponentClass<SelectedAssetRetryButtonProps> = connect(
- undefined,
- mapDispatchToProps,
-)(RetryButton);
diff --git a/packages/instant/src/containers/selected_asset_view_transaction_button.tsx b/packages/instant/src/containers/selected_asset_view_transaction_button.tsx
deleted file mode 100644
index 064b877be..000000000
--- a/packages/instant/src/containers/selected_asset_view_transaction_button.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as _ from 'lodash';
-import * as React from 'react';
-import { connect } from 'react-redux';
-
-import { State } from '../redux/reducer';
-
-import { ViewTransactionButton } from '../components/view_transaction_button';
-import { OrderProcessState } from '../types';
-import { etherscanUtil } from '../util/etherscan';
-
-export interface SelectedAssetViewTransactionButtonProps {}
-
-interface ConnectedState {
- onClick: () => void;
-}
-
-const mapStateToProps = (state: State, _ownProps: {}): ConnectedState => ({
- onClick: () => {
- if (
- state.assetBuyer &&
- (state.buyOrderState.processState === OrderProcessState.PROCESSING ||
- state.buyOrderState.processState === OrderProcessState.SUCCESS)
- ) {
- const etherscanUrl = etherscanUtil.getEtherScanTxnAddressIfExists(
- state.buyOrderState.txHash,
- state.assetBuyer.networkId,
- );
- if (etherscanUrl) {
- window.open(etherscanUrl, '_blank');
- return;
- }
- }
- },
-});
-
-export const SelectedAssetViewTransactionButton: React.ComponentClass<
- SelectedAssetViewTransactionButtonProps
-> = connect(mapStateToProps)(ViewTransactionButton);
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..93ff3db70
--- /dev/null
+++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts
@@ -0,0 +1,116 @@
+import { AssetBuyer } from '@0x/asset-buyer';
+import { AssetProxyId } from '@0x/types';
+import { BigNumber } from '@0x/utils';
+import * as _ from 'lodash';
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+
+import { ERC20AssetAmountInput } 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 { buyQuoteUpdater } from '../util/buy_quote_updater';
+
+export interface SelectedERC20AssetAmountInputProps {
+ fontColor?: ColorOption;
+ startingFontSizePx: number;
+ onSelectAssetClick?: (asset?: ERC20Asset) => void;
+}
+
+interface ConnectedState {
+ assetBuyer: AssetBuyer;
+ value?: BigNumber;
+ asset?: ERC20Asset;
+ isDisabled: boolean;
+ numberOfAssetsAvailable?: number;
+ affiliateInfo?: AffiliateInfo;
+}
+
+interface ConnectedDispatch {
+ updateBuyQuote: (
+ assetBuyer: AssetBuyer,
+ value?: BigNumber,
+ asset?: ERC20Asset,
+ affiliateInfo?: AffiliateInfo,
+ ) => void;
+}
+
+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 debouncedUpdateBuyQuoteAsync = _.debounce(buyQuoteUpdater.updateBuyQuoteAsync.bind(buyQuoteUpdater), 200, {
+ trailing: true,
+}) as typeof buyQuoteUpdater.updateBuyQuoteAsync;
+
+const mapDispatchToProps = (
+ dispatch: Dispatch<Action>,
+ _ownProps: SelectedERC20AssetAmountInputProps,
+): ConnectedDispatch => ({
+ updateBuyQuote: (assetBuyer, value, asset, affiliateInfo) => {
+ // Update the input
+ dispatch(actions.updateSelectedAssetAmount(value));
+ // invalidate the last buy quote.
+ dispatch(actions.updateLatestBuyQuote(undefined));
+ // reset our buy state
+ dispatch(actions.setBuyOrderStateNone());
+
+ if (!_.isUndefined(value) && value.greaterThan(0) && !_.isUndefined(asset)) {
+ // even if it's debounced, give them the illusion it's loading
+ dispatch(actions.setQuoteRequestStatePending());
+ // tslint:disable-next-line:no-floating-promises
+ debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value, true, affiliateInfo);
+ }
+ },
+});
+
+const mergeProps = (
+ connectedState: ConnectedState,
+ connectedDispatch: ConnectedDispatch,
+ ownProps: SelectedERC20AssetAmountInputProps,
+): FinalProps => {
+ return {
+ ...ownProps,
+ asset: connectedState.asset,
+ value: connectedState.value,
+ onChange: (value, asset) => {
+ connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset, connectedState.affiliateInfo);
+ },
+ isDisabled: connectedState.isDisabled,
+ numberOfAssetsAvailable: connectedState.numberOfAssetsAvailable,
+ };
+};
+
+export const SelectedERC20AssetAmountInput: React.ComponentClass<SelectedERC20AssetAmountInputProps> = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+ mergeProps,
+)(ERC20AssetAmountInput);
diff --git a/packages/instant/src/data/asset_data_network_mapping.ts b/packages/instant/src/data/asset_data_network_mapping.ts
index e8ccbf011..4fd0a25ed 100644
--- a/packages/instant/src/data/asset_data_network_mapping.ts
+++ b/packages/instant/src/data/asset_data_network_mapping.ts
@@ -8,8 +8,59 @@ interface AssetDataByNetwork {
}
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
index 3a820a0c4..970b6c383 100644
--- a/packages/instant/src/data/asset_meta_data_map.ts
+++ b/packages/instant/src/data/asset_meta_data_map.ts
@@ -10,5 +10,76 @@ export const assetMetaDataMap: ObjectMap<AssetMetaData> = {
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/index.ts b/packages/instant/src/index.ts
index 54059cdad..6e611dae8 100644
--- a/packages/instant/src/index.ts
+++ b/packages/instant/src/index.ts
@@ -1 +1,2 @@
export { ZeroExInstant, ZeroExInstantProps } from './components/zero_ex_instant';
+export { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './components/zero_ex_instant_overlay';
diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts
index f648b37f2..0274db30c 100644
--- a/packages/instant/src/index.umd.ts
+++ b/packages/instant/src/index.umd.ts
@@ -1,9 +1,56 @@
+import * as _ from 'lodash';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
-import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR } from './constants';
-import { ZeroExInstant, ZeroExInstantProps } from './index';
+import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR, INJECTED_DIV_CLASS, INJECTED_DIV_ID } from './constants';
+import { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './index';
+import { assert } from './util/assert';
-export const render = (props: ZeroExInstantProps, selector: string = DEFAULT_ZERO_EX_CONTAINER_SELECTOR) => {
- ReactDOM.render(React.createElement(ZeroExInstant, props), document.querySelector(selector));
+export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFAULT_ZERO_EX_CONTAINER_SELECTOR) => {
+ assert.isValidOrderSource('orderSource', props.orderSource);
+ if (!_.isUndefined(props.defaultSelectedAssetData)) {
+ assert.isHexString('defaultSelectedAssetData', props.defaultSelectedAssetData);
+ }
+ if (!_.isUndefined(props.additionalAssetMetaDataMap)) {
+ assert.isValidAssetMetaDataMap('props.additionalAssetMetaDataMap', props.additionalAssetMetaDataMap);
+ }
+ if (!_.isUndefined(props.defaultAssetBuyAmount)) {
+ assert.isNumber('props.defaultAssetBuyAmount', props.defaultAssetBuyAmount);
+ }
+ if (!_.isUndefined(props.networkId)) {
+ assert.isNumber('props.networkId', props.networkId);
+ }
+ if (!_.isUndefined(props.availableAssetDatas)) {
+ assert.areValidAssetDatas('availableAssetDatas', props.availableAssetDatas);
+ }
+ if (!_.isUndefined(props.onClose)) {
+ assert.isFunction('props.onClose', props.onClose);
+ }
+ if (!_.isUndefined(props.zIndex)) {
+ assert.isNumber('props.zIndex', props.zIndex);
+ }
+ if (!_.isUndefined(props.affiliateInfo)) {
+ assert.isValidAffiliateInfo('props.affiliateInfo', props.affiliateInfo);
+ }
+ if (!_.isUndefined(props.provider)) {
+ assert.isWeb3Provider('props.provider', props.provider);
+ }
+ assert.isString('selector', selector);
+ const appendToIfExists = document.querySelector(selector);
+ assert.assert(!_.isNull(appendToIfExists), `Could not find div with selector: ${selector}`);
+ const appendTo = appendToIfExists as Element;
+ const injectedDiv = document.createElement('div');
+ injectedDiv.setAttribute('id', INJECTED_DIV_ID);
+ injectedDiv.setAttribute('class', INJECTED_DIV_CLASS);
+ appendTo.appendChild(injectedDiv);
+ const instantOverlayProps = {
+ ...props,
+ onClose: () => {
+ appendTo.removeChild(injectedDiv);
+ if (!_.isUndefined(props.onClose)) {
+ props.onClose();
+ }
+ },
+ };
+ ReactDOM.render(React.createElement(ZeroExInstantOverlay, instantOverlayProps), injectedDiv);
};
diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts
index 5a4099f15..8947c6c97 100644
--- a/packages/instant/src/redux/actions.ts
+++ b/packages/instant/src/redux/actions.ts
@@ -2,7 +2,7 @@ import { BuyQuote } from '@0x/asset-buyer';
import { BigNumber } from '@0x/utils';
import * as _ from 'lodash';
-import { ActionsUnion, OrderState } from '../types';
+import { ActionsUnion, AddressAndEthBalanceInWei, Asset } from '../types';
export interface PlainAction<T extends string> {
type: T;
@@ -21,28 +21,48 @@ function createAction<T extends string, P>(type: T, data?: P): PlainAction<T> |
}
export enum ActionTypes {
+ SET_ACCOUNT_STATE_LOADING = 'SET_ACCOUNT_STATE_LOADING',
+ SET_ACCOUNT_STATE_LOCKED = 'SET_ACCOUNT_STATE_LOCKED',
+ SET_ACCOUNT_STATE_READY = 'SET_ACCOUNT_STATE_READY',
+ UPDATE_ACCOUNT_ETH_BALANCE = 'UPDATE_ACCOUNT_ETH_BALANCE',
UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE',
UPDATE_SELECTED_ASSET_AMOUNT = 'UPDATE_SELECTED_ASSET_AMOUNT',
- UPDATE_BUY_ORDER_STATE = 'UPDATE_BUY_ORDER_STATE',
+ 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 = 'SET_ERROR',
+ SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE',
HIDE_ERROR = 'HIDE_ERROR',
CLEAR_ERROR = 'CLEAR_ERROR',
RESET_AMOUNT = 'RESET_AMOUNT',
}
export const actions = {
+ setAccountStateLoading: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOADING),
+ setAccountStateLocked: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOCKED),
+ setAccountStateReady: (address: string) => createAction(ActionTypes.SET_ACCOUNT_STATE_READY, address),
+ updateAccountEthBalance: (addressAndBalance: AddressAndEthBalanceInWei) =>
+ createAction(ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE, addressAndBalance),
updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price),
updateSelectedAssetAmount: (amount?: BigNumber) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT, amount),
- updateBuyOrderState: (orderState: OrderState) => createAction(ActionTypes.UPDATE_BUY_ORDER_STATE, orderState),
+ 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: (assetData?: string) => createAction(ActionTypes.UPDATE_SELECTED_ASSET, assetData),
+ 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),
- setError: (error?: any) => createAction(ActionTypes.SET_ERROR, error),
+ 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
index 4ed89bdc3..b920ac914 100644
--- a/packages/instant/src/redux/async_data.ts
+++ b/packages/instant/src/redux/async_data.ts
@@ -1,22 +1,103 @@
+import { AssetProxyId } from '@0x/types';
+import * as _ from 'lodash';
+
import { BIG_NUMBER_ZERO } from '../constants';
+import { AccountState, ERC20Asset, OrderProcessState } from '../types';
+import { assetUtils } from '../util/asset';
+import { buyQuoteUpdater } from '../util/buy_quote_updater';
import { coinbaseApi } from '../util/coinbase_api';
+import { errorFlasher } from '../util/error_flasher';
-import { ActionTypes } from './actions';
-
+import { actions } from './actions';
import { Store } from './store';
export const asyncData = {
- fetchAndDispatchToStore: async (store: Store) => {
- let ethUsdPrice = BIG_NUMBER_ZERO;
+ 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 {
- ethUsdPrice = await coinbaseApi.getEthUsdPrice();
+ const assetDatas = await assetBuyer.getAvailableAssetDatasAsync();
+ const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, assetMetaDataMap, network);
+ store.dispatch(actions.setAvailableAssets(assets));
} catch (e) {
- // ignore
- } finally {
- store.dispatch({
- type: ActionTypes.UPDATE_ETH_USD_PRICE,
- data: ethUsdPrice,
- });
+ const errorMessage = 'Could not find any assets';
+ errorFlasher.flashNewErrorMessage(store.dispatch, errorMessage);
+ // On error, just specify that none are available
+ store.dispatch(actions.setAvailableAssets([]));
+ }
+ },
+ fetchAccountInfoAndDispatchToStore: async (options: { store: Store; shouldSetToLoading: boolean }) => {
+ const { store, shouldSetToLoading } = options;
+ const { providerState } = store.getState();
+ const web3Wrapper = providerState.web3Wrapper;
+ const provider = providerState.provider;
+ if (shouldSetToLoading && providerState.account.state !== AccountState.Loading) {
+ store.dispatch(actions.setAccountStateLoading());
+ }
+ let availableAddresses: string[];
+ try {
+ // TODO(bmillman): Add support at the web3Wrapper level for calling `eth_requestAccounts` instead of calling enable here
+ const isPrivacyModeEnabled = !_.isUndefined((provider as any).enable);
+ availableAddresses = isPrivacyModeEnabled
+ ? await (provider as any).enable()
+ : await web3Wrapper.getAvailableAddressesAsync();
+ } catch (e) {
+ store.dispatch(actions.setAccountStateLocked());
+ return;
+ }
+ if (!_.isEmpty(availableAddresses)) {
+ const activeAddress = availableAddresses[0];
+ store.dispatch(actions.setAccountStateReady(activeAddress));
+ // tslint:disable-next-line:no-floating-promises
+ asyncData.fetchAccountBalanceAndDispatchToStore(store);
+ } else {
+ store.dispatch(actions.setAccountStateLocked());
+ }
+ },
+ fetchAccountBalanceAndDispatchToStore: async (store: Store) => {
+ const { providerState } = store.getState();
+ const web3Wrapper = providerState.web3Wrapper;
+ const account = providerState.account;
+ if (account.state !== AccountState.Ready) {
+ return;
+ }
+ try {
+ const address = account.address;
+ const ethBalanceInWei = await web3Wrapper.getBalanceInWeiAsync(address);
+ store.dispatch(actions.updateAccountEthBalance({ address, ethBalanceInWei }));
+ } catch (e) {
+ // leave balance as is
+ return;
+ }
+ },
+ fetchCurrentBuyQuoteAndDispatchToStore: async (options: { store: Store; shouldSetPending: boolean }) => {
+ const { store, shouldSetPending } = options;
+ const { buyOrderState, providerState, selectedAsset, selectedAssetAmount, affiliateInfo } = store.getState();
+ const assetBuyer = providerState.assetBuyer;
+ if (
+ !_.isUndefined(selectedAssetAmount) &&
+ !_.isUndefined(selectedAsset) &&
+ buyOrderState.processState === OrderProcessState.None &&
+ selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20
+ ) {
+ await buyQuoteUpdater.updateBuyQuoteAsync(
+ assetBuyer,
+ store.dispatch,
+ selectedAsset as ERC20Asset,
+ selectedAssetAmount,
+ shouldSetPending,
+ affiliateInfo,
+ );
}
},
};
diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts
index 25d0092b2..ef46fdd9d 100644
--- a/packages/instant/src/redux/reducer.ts
+++ b/packages/instant/src/redux/reducer.ts
@@ -1,10 +1,16 @@
-import { AssetBuyer, BuyQuote } from '@0x/asset-buyer';
-import { ObjectMap } from '@0x/types';
+import { BuyQuote } from '@0x/asset-buyer';
+import { AssetProxyId, ObjectMap } from '@0x/types';
import { BigNumber } from '@0x/utils';
+import { Web3Wrapper } from '@0x/web3-wrapper';
import * as _ from 'lodash';
+import { LOADING_ACCOUNT, LOCKED_ACCOUNT } from '../constants';
import { assetMetaDataMap } from '../data/asset_meta_data_map';
import {
+ Account,
+ AccountReady,
+ AccountState,
+ AffiliateInfo,
Asset,
AssetMetaData,
AsyncProcessState,
@@ -12,112 +18,240 @@ import {
Network,
OrderProcessState,
OrderState,
+ ProviderState,
} from '../types';
-import { assetUtils } from '../util/asset';
import { Action, ActionTypes } from './actions';
-export interface State {
+// State that is required and we have defaults for, before props are passed in
+export interface DefaultState {
network: Network;
- assetBuyer?: AssetBuyer;
assetMetaDataMap: ObjectMap<AssetMetaData>;
- selectedAsset?: Asset;
- selectedAssetAmount?: BigNumber;
buyOrderState: OrderState;
- ethUsdPrice?: BigNumber;
- latestBuyQuote?: BuyQuote;
+ latestErrorDisplayStatus: DisplayStatus;
quoteRequestState: AsyncProcessState;
- latestError?: any;
- latestErrorDisplay: DisplayStatus;
}
-export const INITIAL_STATE: State = {
+// 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,
- selectedAssetAmount: undefined,
assetMetaDataMap,
- buyOrderState: { processState: OrderProcessState.NONE },
- ethUsdPrice: undefined,
- latestBuyQuote: undefined,
- latestError: undefined,
- latestErrorDisplay: DisplayStatus.Hidden,
- quoteRequestState: AsyncProcessState.NONE,
+ buyOrderState: { processState: OrderProcessState.None },
+ latestErrorDisplayStatus: DisplayStatus.Hidden,
+ quoteRequestState: AsyncProcessState.None,
};
-export const reducer = (state: State = INITIAL_STATE, 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:
- return {
- ...state,
- latestBuyQuote: action.data,
- quoteRequestState: AsyncProcessState.SUCCESS,
- };
- 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.UPDATE_BUY_ORDER_STATE:
- return {
- ...state,
- buyOrderState: action.data,
- };
- case ActionTypes.SET_ERROR:
- return {
- ...state,
- latestError: action.data,
- latestErrorDisplay: DisplayStatus.Present,
- };
- case ActionTypes.HIDE_ERROR:
- return {
- ...state,
- latestErrorDisplay: DisplayStatus.Hidden,
- };
- case ActionTypes.CLEAR_ERROR:
- return {
- ...state,
- latestError: undefined,
- latestErrorDisplay: DisplayStatus.Hidden,
- };
- case ActionTypes.UPDATE_SELECTED_ASSET:
- const newSelectedAssetData = action.data;
- let newSelectedAsset: Asset | undefined;
- if (!_.isUndefined(newSelectedAssetData)) {
- newSelectedAsset = assetUtils.createAssetFromAssetData(
- newSelectedAssetData,
- state.assetMetaDataMap,
- state.network,
- );
+export const createReducer = (initialState: State) => {
+ const reducer = (state: State = initialState, action: Action): State => {
+ switch (action.type) {
+ case ActionTypes.SET_ACCOUNT_STATE_LOADING:
+ return reduceStateWithAccount(state, LOADING_ACCOUNT);
+ case ActionTypes.SET_ACCOUNT_STATE_LOCKED:
+ return reduceStateWithAccount(state, LOCKED_ACCOUNT);
+ case ActionTypes.SET_ACCOUNT_STATE_READY: {
+ const account: AccountReady = {
+ state: AccountState.Ready,
+ address: action.data,
+ };
+ return reduceStateWithAccount(state, account);
+ }
+ case ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE: {
+ const { address, ethBalanceInWei } = action.data;
+ const currentAccount = state.providerState.account;
+ if (currentAccount.state !== AccountState.Ready || currentAccount.address !== address) {
+ return state;
+ } else {
+ const newAccount: AccountReady = {
+ ...currentAccount,
+ ethBalanceInWei,
+ };
+ return reduceStateWithAccount(state, newAccount);
+ }
}
- return {
- ...state,
- selectedAsset: newSelectedAsset,
- };
- case ActionTypes.RESET_AMOUNT:
- return {
- ...state,
- latestBuyQuote: undefined,
- quoteRequestState: AsyncProcessState.NONE,
- buyOrderState: { processState: OrderProcessState.NONE },
- selectedAssetAmount: undefined,
- };
- default:
- return state;
+ 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 reduceStateWithAccount = (state: State, account: Account) => {
+ const oldProviderState = state.providerState;
+ const newProviderState: ProviderState = {
+ ...oldProviderState,
+ account,
+ };
+ return {
+ ...state,
+ providerState: newProviderState,
+ };
+};
+
+const doesBuyQuoteMatchState = (buyQuote: BuyQuote, state: State): boolean => {
+ const selectedAssetIfExists = state.selectedAsset;
+ const 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
index 01deb8690..20710765d 100644
--- a/packages/instant/src/redux/store.ts
+++ b/packages/instant/src/redux/store.ts
@@ -2,12 +2,13 @@ import * as _ from 'lodash';
import { createStore, Store as ReduxStore } from 'redux';
import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly';
-import { reducer, State } from './reducer';
+import { createReducer, State } from './reducer';
export type Store = ReduxStore<State>;
export const store = {
- create: (state: State): Store => {
- return createStore(reducer, state, devToolsEnhancer({}));
+ 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
index 975a30a61..92450502d 100644
--- a/packages/instant/src/style/fonts.ts
+++ b/packages/instant/src/style/fonts.ts
@@ -1,10 +1,10 @@
-import { injectGlobal } from './theme';
-
export const fonts = {
include: () => {
// Inject the inter-ui font into the page
- return injectGlobal`
- @import url('https://rsms.me/inter/inter-ui.css');
- `;
+ const appendTo = document.head || document.getElementsByTagName('head')[0] || document.body;
+ const style = document.createElement('style');
+ style.type = 'text/css';
+ style.appendChild(document.createTextNode(`@import url('https://rsms.me/inter/inter-ui.css')`));
+ appendTo.appendChild(style);
},
};
diff --git a/packages/instant/src/style/media.ts b/packages/instant/src/style/media.ts
new file mode 100644
index 000000000..bbf376694
--- /dev/null
+++ b/packages/instant/src/style/media.ts
@@ -0,0 +1,51 @@
+import { InterpolationValue } from 'styled-components';
+
+import { css } from './theme';
+
+export enum ScreenWidths {
+ Sm = 40,
+ Md = 52,
+ Lg = 64,
+}
+
+export const generateMediaWrapper = (screenWidth: ScreenWidths) => (...args: any[]) => css`
+ @media (max-width: ${screenWidth}em) {
+ ${css.apply(css, args)};
+ }
+`;
+
+export const media = {
+ small: generateMediaWrapper(ScreenWidths.Sm),
+ medium: generateMediaWrapper(ScreenWidths.Md),
+ large: generateMediaWrapper(ScreenWidths.Lg),
+};
+
+export interface ScreenSpecification<T> {
+ default: T;
+ sm?: T;
+ md?: T;
+ lg?: T;
+}
+export type OptionallyScreenSpecific<T> = T | ScreenSpecification<T>;
+export type MediaChoice = OptionallyScreenSpecific<string>;
+/**
+ * Given a css property name and a OptionallyScreenSpecific value,
+ * generates css properties with screen-specific viewport styling
+ */
+export function stylesForMedia<T extends string | number>(
+ cssPropertyName: string,
+ choice: OptionallyScreenSpecific<T>,
+): InterpolationValue[] {
+ if (typeof choice === 'object') {
+ return css`
+ ${cssPropertyName}: ${choice.default};
+ ${choice.lg && media.large`${cssPropertyName}: ${choice.lg}`}
+ ${choice.md && media.medium`${cssPropertyName}: ${choice.md}`}
+ ${choice.sm && media.small`${cssPropertyName}: ${choice.sm}`}
+ `;
+ } else {
+ return css`
+ ${cssPropertyName}: ${choice};
+ `;
+ }
+}
diff --git a/packages/instant/src/style/theme.ts b/packages/instant/src/style/theme.ts
index d26c816c1..1e9f55e00 100644
--- a/packages/instant/src/style/theme.ts
+++ b/packages/instant/src/style/theme.ts
@@ -1,6 +1,6 @@
import * as styledComponents from 'styled-components';
-const { default: styled, css, injectGlobal, keyframes, ThemeProvider } = styledComponents;
+const { default: styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider } = styledComponents;
export type Theme = { [key in ColorOption]: string };
@@ -10,22 +10,35 @@ export enum ColorOption {
lightGrey = 'lightGrey',
grey = 'grey',
feintGrey = 'feintGrey',
+ lightestGrey = 'lightestGrey',
darkGrey = 'darkGrey',
white = 'white',
lightOrange = 'lightOrange',
darkOrange = 'darkOrange',
+ green = 'green',
+ red = 'red',
}
export const theme: Theme = {
- primaryColor: '#512D80',
+ primaryColor: '#333',
black: 'black',
lightGrey: '#999999',
grey: '#666666',
feintGrey: '#DEDEDE',
+ lightestGrey: '#EEEEEE',
darkGrey: '#333333',
white: 'white',
lightOrange: '#F9F2ED',
darkOrange: '#F2994C',
+ green: '#3CB34F',
+ red: '#D00000',
};
-export { styled, css, injectGlobal, keyframes, ThemeProvider };
+export const transparentWhite = 'rgba(255,255,255,0.3)';
+export const completelyTransparent = 'rga(0, 0, 0, 0)';
+
+export const generateOverlayBlack = (opacity = 0.6) => {
+ return `rgba(0, 0, 0, ${opacity})`;
+};
+
+export { styled, css, keyframes, withTheme, createGlobalStyle, ThemeProvider };
diff --git a/packages/instant/src/style/z_index.ts b/packages/instant/src/style/z_index.ts
new file mode 100644
index 000000000..ba2d27a17
--- /dev/null
+++ b/packages/instant/src/style/z_index.ts
@@ -0,0 +1,9 @@
+export const zIndex = {
+ errorPopBehind: 10,
+ mainContainer: 20,
+ dropdownItems: 30,
+ panel: 40,
+ containerOverlay: 45,
+ errorPopup: 50,
+ overlayDefault: 100,
+};
diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts
index c63371fb4..b43a82d46 100644
--- a/packages/instant/src/types.ts
+++ b/packages/instant/src/types.ts
@@ -1,27 +1,38 @@
-import { AssetProxyId, ObjectMap } from '@0x/types';
+import { AssetBuyer, BigNumber } from '@0x/asset-buyer';
+import { AssetProxyId, ObjectMap, SignedOrder } from '@0x/types';
+import { Web3Wrapper } from '@0x/web3-wrapper';
+import { Provider } from 'ethereum-types';
// Reusable
+export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
+export type Maybe<T> = T | undefined;
export enum AsyncProcessState {
- NONE = 'None',
- PENDING = 'Pending',
- SUCCESS = 'Success',
- FAILURE = 'Failure',
+ None = 'NONE',
+ Pending = 'PENDING',
+ Success = 'SUCCESS',
+ Failure = 'FAILURE',
}
export enum OrderProcessState {
- NONE = 'None',
- AWAITING_SIGNATURE = 'Awaiting Signature',
- PROCESSING = 'Processing',
- SUCCESS = 'Success',
- FAILURE = 'Failure',
+ None = 'NONE',
+ Validating = 'VALIDATING',
+ Processing = 'PROCESSING',
+ Success = 'SUCCESS',
+ Failure = 'FAILURE',
+}
+
+export interface SimulatedProgress {
+ startTimeUnix: number;
+ expectedEndTimeUnix: number;
}
interface OrderStatePreTx {
- processState: OrderProcessState.NONE | OrderProcessState.AWAITING_SIGNATURE;
+ processState: OrderProcessState.None | OrderProcessState.Validating;
}
interface OrderStatePostTx {
- processState: OrderProcessState.PROCESSING | OrderProcessState.SUCCESS | OrderProcessState.FAILURE;
+ processState: OrderProcessState.Processing | OrderProcessState.Success | OrderProcessState.Failure;
txHash: string;
+ progress: SimulatedProgress;
}
export type OrderState = OrderStatePreTx | OrderStatePostTx;
@@ -39,12 +50,13 @@ export interface ERC20AssetMetaData {
decimals: number;
primaryColor?: string;
symbol: string;
+ name: string;
}
export interface ERC721AssetMetaData {
assetProxyId: AssetProxyId.ERC721;
name: string;
- representationUrl?: string;
+ imageUrl?: string;
primaryColor?: string;
}
@@ -72,4 +84,44 @@ export enum Network {
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 {
+ None = 'NONE,',
+ Loading = 'LOADING',
+ Ready = 'READY',
+ Locked = 'LOCKED',
+}
+
+export interface AccountReady {
+ state: AccountState.Ready;
+ address: string;
+ ethBalanceInWei?: BigNumber;
+}
+export interface AccountNotReady {
+ state: AccountState.None | AccountState.Loading | AccountState.Locked;
+}
+
+export type Account = AccountReady | AccountNotReady;
+
+export type OrderSource = string | SignedOrder[];
+
+export interface AddressAndEthBalanceInWei {
+ address: string;
+ ethBalanceInWei: BigNumber;
}
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
index 4e3b2b946..fbfbb19f3 100644
--- a/packages/instant/src/util/asset.ts
+++ b/packages/instant/src/util/asset.ts
@@ -2,10 +2,34 @@ import { AssetProxyId, ObjectMap } from '@0x/types';
import * as _ from 'lodash';
import { assetDataNetworkMapping } from '../data/asset_data_network_mapping';
-import { Asset, AssetMetaData, Network, ZeroExInstantError } from '../types';
+import { Asset, AssetMetaData, ERC20Asset, Network, ZeroExInstantError } from '../types';
export const assetUtils = {
- createAssetFromAssetData: (
+ 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,
@@ -16,16 +40,33 @@ export const assetUtils = {
};
},
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) {
- mainnetAssetData = assetUtils.getAssociatedAssetDataIfExists(assetData, network);
+ 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)) {
- throw new Error(ZeroExInstantError.AssetMetaDataNotAvailable);
+ return;
}
- const metaData = metaDataMap[mainnetAssetData];
+ const metaData = metaDataMap[mainnetAssetData.toLowerCase()];
if (_.isUndefined(metaData)) {
- throw new Error(ZeroExInstantError.AssetMetaDataNotAvailable);
+ return;
}
return metaData;
},
@@ -43,6 +84,16 @@ export const assetUtils = {
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)) {
@@ -50,4 +101,11 @@ export const assetUtils = {
}
return assetDataGroupIfExists[Network.Mainnet];
},
+ getERC20AssetsFromAssets: (assets: Asset[]): ERC20Asset[] => {
+ const erc20sOrUndefined = _.map(
+ assets,
+ asset => (asset.metaData.assetProxyId === AssetProxyId.ERC20 ? (asset as ERC20Asset) : undefined),
+ );
+ return _.compact(erc20sOrUndefined);
+ },
};
diff --git a/packages/instant/src/util/asset_buyer_factory.ts b/packages/instant/src/util/asset_buyer_factory.ts
new file mode 100644
index 000000000..5ba46223c
--- /dev/null
+++ b/packages/instant/src/util/asset_buyer_factory.ts
@@ -0,0 +1,17 @@
+import { AssetBuyer, AssetBuyerOpts } from '@0x/asset-buyer';
+import { Provider } from 'ethereum-types';
+import * as _ from 'lodash';
+
+import { Network, OrderSource } from '../types';
+
+export const assetBuyerFactory = {
+ getAssetBuyer: (provider: Provider, orderSource: OrderSource, network: Network): AssetBuyer => {
+ const assetBuyerOptions: Partial<AssetBuyerOpts> = {
+ networkId: network,
+ };
+ const assetBuyer = _.isString(orderSource)
+ ? AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl(provider, orderSource, assetBuyerOptions)
+ : AssetBuyer.getAssetBuyerForProvidedOrders(provider, orderSource, assetBuyerOptions);
+ return assetBuyer;
+ },
+};
diff --git a/packages/instant/src/util/buy_quote_updater.ts b/packages/instant/src/util/buy_quote_updater.ts
new file mode 100644
index 000000000..c33e28f1c
--- /dev/null
+++ b/packages/instant/src/util/buy_quote_updater.ts
@@ -0,0 +1,59 @@
+import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
+import { BigNumber } from '@0x/utils';
+import { Web3Wrapper } from '@0x/web3-wrapper';
+import * as _ from 'lodash';
+import { Dispatch } from 'redux';
+import { oc } from 'ts-optchain';
+
+import { Action, actions } from '../redux/actions';
+import { AffiliateInfo, ERC20Asset } from '../types';
+import { assetUtils } from '../util/asset';
+import { errorFlasher } from '../util/error_flasher';
+
+export const buyQuoteUpdater = {
+ updateBuyQuoteAsync: async (
+ assetBuyer: AssetBuyer,
+ dispatch: Dispatch<Action>,
+ asset: ERC20Asset,
+ assetAmount: BigNumber,
+ setPending = true,
+ affiliateInfo?: AffiliateInfo,
+ ): Promise<void> => {
+ // get a new buy quote.
+ const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals);
+ if (setPending) {
+ // 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));
+ },
+};
diff --git a/packages/instant/src/util/coinbase_api.ts b/packages/instant/src/util/coinbase_api.ts
index 080421f98..faac8d82d 100644
--- a/packages/instant/src/util/coinbase_api.ts
+++ b/packages/instant/src/util/coinbase_api.ts
@@ -1,9 +1,10 @@
-import { BigNumber } from '@0x/utils';
+import { BigNumber, fetchAsync } from '@0x/utils';
+
+import { COINBASE_API_BASE_URL } from '../constants';
-const baseEndpoint = 'https://api.coinbase.com/v2';
export const coinbaseApi = {
getEthUsdPrice: async (): Promise<BigNumber> => {
- const res = await fetch(`${baseEndpoint}/prices/ETH-USD/buy`);
+ 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.ts b/packages/instant/src/util/error.ts
deleted file mode 100644
index 64c1f4885..000000000
--- a/packages/instant/src/util/error.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { AssetBuyerError } from '@0x/asset-buyer';
-import { Dispatch } from 'redux';
-
-import { Action, actions } from '../redux/actions';
-import { Asset } from '../types';
-
-import { assetUtils } from './asset';
-
-class ErrorFlasher {
- private _timeoutId?: number;
- public flashNewError(dispatch: Dispatch<Action>, error: any, delayMs: number = 7000): void {
- this._clearTimeout();
-
- // dispatch new message
- dispatch(actions.setError(error));
-
- 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);
- }
- }
-}
-
-const humanReadableMessageForError = (error: Error, asset?: Asset): string | undefined => {
- const hasInsufficientLiquidity =
- error.message === AssetBuyerError.InsufficientAssetLiquidity ||
- error.message === AssetBuyerError.InsufficientZrxLiquidity;
- if (hasInsufficientLiquidity) {
- const assetName = assetUtils.bestNameForAsset(asset, 'of this asset');
- return `Not enough ${assetName} available`;
- }
-
- if (
- error.message === AssetBuyerError.StandardRelayerApiError ||
- error.message.startsWith(AssetBuyerError.AssetUnavailable)
- ) {
- const assetName = assetUtils.bestNameForAsset(asset, 'This asset');
- return `${assetName} is currently unavailable`;
- }
-
- if (error.message === AssetBuyerError.SignatureRequestDenied) {
- return 'You denied this transaction';
- }
-
- return undefined;
-};
-
-export const errorUtil = {
- errorFlasher: new ErrorFlasher(),
- errorDescription: (error?: any, asset?: Asset): { icon: string; message: string } => {
- let bestMessage: string | undefined;
- if (error instanceof Error) {
- bestMessage = humanReadableMessageForError(error, asset);
- }
- return {
- icon: '😢',
- message: bestMessage || 'Something went wrong...',
- };
- },
-};
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
index cfc2578a3..4d62c4d9f 100644
--- a/packages/instant/src/util/etherscan.ts
+++ b/packages/instant/src/util/etherscan.ts
@@ -21,4 +21,11 @@ export const etherscanUtil = {
}
return `https://${prefix}etherscan.io/tx/${txHash}`;
},
+ getEtherScanEthAddressIfExists: (ethAddress: string, networkId: number) => {
+ const prefix = etherscanPrefix(networkId);
+ if (_.isUndefined(prefix)) {
+ return;
+ }
+ return `https://${prefix}etherscan.io/address/${ethAddress}`;
+ },
};
diff --git a/packages/instant/src/util/format.ts b/packages/instant/src/util/format.ts
index 8482b1526..44661d697 100644
--- a/packages/instant/src/util/format.ts
+++ b/packages/instant/src/util/format.ts
@@ -2,7 +2,7 @@ import { BigNumber } from '@0x/utils';
import { Web3Wrapper } from '@0x/web3-wrapper';
import * as _ from 'lodash';
-import { ethDecimals } from '../constants';
+import { ETH_DECIMALS } from '../constants';
export const format = {
ethBaseAmount: (
@@ -13,7 +13,7 @@ export const format = {
if (_.isUndefined(ethBaseAmount)) {
return defaultText;
}
- const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, ethDecimals);
+ const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, ETH_DECIMALS);
return format.ethUnitAmount(ethUnitAmount, decimalPlaces);
},
ethUnitAmount: (
@@ -24,7 +24,7 @@ export const format = {
if (_.isUndefined(ethUnitAmount)) {
return defaultText;
}
- const roundedAmount = ethUnitAmount.round(decimalPlaces);
+ const roundedAmount = ethUnitAmount.round(decimalPlaces).toDigits(decimalPlaces);
return `${roundedAmount} ETH`;
},
ethBaseAmountInUsd: (
@@ -36,7 +36,7 @@ export const format = {
if (_.isUndefined(ethBaseAmount) || _.isUndefined(ethUsdPrice)) {
return defaultText;
}
- const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, ethDecimals);
+ const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, ETH_DECIMALS);
return format.ethUnitAmountInUsd(ethUnitAmount, ethUsdPrice, decimalPlaces);
},
ethUnitAmountInUsd: (
@@ -50,4 +50,7 @@ export const format = {
}
return `$${ethUnitAmount.mul(ethUsdPrice).toFixed(decimalPlaces)}`;
},
+ ethAddress: (address: string): string => {
+ return `0x${address.slice(2, 7)}…${address.slice(-5)}`;
+ },
};
diff --git a/packages/instant/src/util/gas_price_estimator.ts b/packages/instant/src/util/gas_price_estimator.ts
new file mode 100644
index 000000000..6b15809a3
--- /dev/null
+++ b/packages/instant/src/util/gas_price_estimator.ts
@@ -0,0 +1,62 @@
+import { BigNumber, fetchAsync } from '@0x/utils';
+
+import {
+ DEFAULT_ESTIMATED_TRANSACTION_TIME_MS,
+ DEFAULT_GAS_PRICE,
+ ETH_GAS_STATION_API_BASE_URL,
+ GWEI_IN_WEI,
+} from '../constants';
+
+interface EthGasStationResult {
+ average: number;
+ fastestWait: number;
+ fastWait: number;
+ fast: number;
+ safeLowWait: number;
+ blockNum: number;
+ avgWait: number;
+ block_time: number;
+ speed: number;
+ fastest: number;
+ safeLow: number;
+}
+
+interface GasInfo {
+ gasPriceInWei: BigNumber;
+ estimatedTimeMs: number;
+}
+
+const fetchFastAmountInWeiAsync = async (): Promise<GasInfo> => {
+ const res = await fetchAsync(`${ETH_GAS_STATION_API_BASE_URL}/json/ethgasAPI.json`);
+ const gasInfo = (await res.json()) as EthGasStationResult;
+ // Eth Gas Station result is gwei * 10
+ const gasPriceInGwei = new BigNumber(gasInfo.fast / 10);
+ // Time is in minutes
+ const estimatedTimeMs = gasInfo.fastWait * 60 * 1000; // Minutes to MS
+ return { gasPriceInWei: gasPriceInGwei.mul(GWEI_IN_WEI), estimatedTimeMs };
+};
+
+export class GasPriceEstimator {
+ private _lastFetched?: GasInfo;
+ public async getGasInfoAsync(): Promise<GasInfo> {
+ let fetchedAmount: GasInfo | undefined;
+ try {
+ fetchedAmount = await fetchFastAmountInWeiAsync();
+ } catch {
+ fetchedAmount = undefined;
+ }
+
+ if (fetchedAmount) {
+ this._lastFetched = fetchedAmount;
+ }
+
+ return (
+ fetchedAmount ||
+ this._lastFetched || {
+ gasPriceInWei: DEFAULT_GAS_PRICE,
+ estimatedTimeMs: DEFAULT_ESTIMATED_TRANSACTION_TIME_MS,
+ }
+ );
+ }
+}
+export const gasPriceEstimator = new GasPriceEstimator();
diff --git a/packages/instant/src/util/heartbeater.ts b/packages/instant/src/util/heartbeater.ts
new file mode 100644
index 000000000..e700d489e
--- /dev/null
+++ b/packages/instant/src/util/heartbeater.ts
@@ -0,0 +1,35 @@
+import { intervalUtils } from '@0x/utils';
+import * as _ from 'lodash';
+
+type HeartbeatableFunction = () => Promise<void>;
+export class Heartbeater {
+ private _intervalId?: NodeJS.Timer;
+ private readonly _performImmediatelyOnStart: boolean;
+ private readonly _performFunction: HeartbeatableFunction;
+
+ public constructor(performingFunctionAsync: HeartbeatableFunction, performImmediatelyOnStart: boolean) {
+ this._performFunction = performingFunctionAsync;
+ this._performImmediatelyOnStart = performImmediatelyOnStart;
+ }
+
+ public start(intervalTimeMs: number): void {
+ if (!_.isUndefined(this._intervalId)) {
+ throw new Error('Heartbeat is running, please stop before restarting');
+ }
+
+ if (this._performImmediatelyOnStart) {
+ // tslint:disable-next-line:no-floating-promises
+ this._performFunction();
+ }
+
+ // tslint:disable-next-line:no-unbound-method
+ this._intervalId = intervalUtils.setAsyncExcludingInterval(this._performFunction, intervalTimeMs, _.noop);
+ }
+
+ public stop(): void {
+ if (this._intervalId) {
+ intervalUtils.clearInterval(this._intervalId);
+ }
+ this._intervalId = undefined;
+ }
+}
diff --git a/packages/instant/src/util/heartbeater_factory.ts b/packages/instant/src/util/heartbeater_factory.ts
new file mode 100644
index 000000000..96a8ac4e6
--- /dev/null
+++ b/packages/instant/src/util/heartbeater_factory.ts
@@ -0,0 +1,22 @@
+import { asyncData } from '../redux/async_data';
+import { Store } from '../redux/store';
+
+import { Heartbeater } from './heartbeater';
+
+export interface HeartbeatFactoryOptions {
+ store: Store;
+ shouldPerformImmediatelyOnStart: boolean;
+}
+export const generateAccountHeartbeater = (options: HeartbeatFactoryOptions): Heartbeater => {
+ const { store, shouldPerformImmediatelyOnStart } = options;
+ return new Heartbeater(async () => {
+ await asyncData.fetchAccountInfoAndDispatchToStore({ store, shouldSetToLoading: false });
+ }, shouldPerformImmediatelyOnStart);
+};
+
+export const generateBuyQuoteHeartbeater = (options: HeartbeatFactoryOptions): Heartbeater => {
+ const { store, shouldPerformImmediatelyOnStart } = options;
+ return new Heartbeater(async () => {
+ await asyncData.fetchCurrentBuyQuoteAndDispatchToStore({ store, shouldSetPending: false });
+ }, shouldPerformImmediatelyOnStart);
+};
diff --git a/packages/instant/src/util/maybe_big_number.ts b/packages/instant/src/util/maybe_big_number.ts
new file mode 100644
index 000000000..9d3746e10
--- /dev/null
+++ b/packages/instant/src/util/maybe_big_number.ts
@@ -0,0 +1,25 @@
+import { BigNumber } from '@0x/utils';
+import * as _ from 'lodash';
+
+import { Maybe } from '../types';
+
+export const maybeBigNumberUtil = {
+ // converts a string to a Maybe<BigNumber>
+ // if string is a NaN, considered undefined
+ stringToMaybeBigNumber: (stringValue: string): Maybe<BigNumber> => {
+ let validBigNumber: BigNumber;
+ try {
+ validBigNumber = new BigNumber(stringValue);
+ } catch {
+ return undefined;
+ }
+
+ return validBigNumber.isNaN() ? undefined : validBigNumber;
+ },
+ areMaybeBigNumbersEqual: (val1: Maybe<BigNumber>, val2: Maybe<BigNumber>): boolean => {
+ if (!_.isUndefined(val1) && !_.isUndefined(val2)) {
+ return val1.equals(val2);
+ }
+ return _.isUndefined(val1) && _.isUndefined(val2);
+ },
+};
diff --git a/packages/instant/src/util/provider.ts b/packages/instant/src/util/provider.ts
deleted file mode 100644
index 49705fd11..000000000
--- a/packages/instant/src/util/provider.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Provider } from 'ethereum-types';
-
-export const getProvider = (): Provider => {
- const injectedWeb3 = (window as any).web3 || undefined;
- try {
- // Use MetaMask/Mist provider
- return injectedWeb3.currentProvider;
- } catch (err) {
- // Throws when user doesn't have MetaMask/Mist running
- throw new Error(`No injected web3 found: ${err}`);
- }
-};
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..3281f6bfb
--- /dev/null
+++ b/packages/instant/src/util/provider_state_factory.ts
@@ -0,0 +1,63 @@
+import { Web3Wrapper } from '@0x/web3-wrapper';
+import { Provider } from 'ethereum-types';
+import * as _ from 'lodash';
+
+import { LOADING_ACCOUNT, NO_ACCOUNT } from '../constants';
+import { Maybe, Network, OrderSource, ProviderState } from '../types';
+
+import { assetBuyerFactory } from './asset_buyer_factory';
+import { providerFactory } from './provider_factory';
+
+export const providerStateFactory = {
+ getInitialProviderState: (orderSource: OrderSource, network: Network, provider?: Provider): ProviderState => {
+ if (!_.isUndefined(provider)) {
+ return providerStateFactory.getInitialProviderStateFromProvider(orderSource, network, provider);
+ }
+ const providerStateFromWindowIfExits = providerStateFactory.getInitialProviderStateFromWindowIfExists(
+ orderSource,
+ network,
+ );
+ if (providerStateFromWindowIfExits) {
+ return providerStateFromWindowIfExits;
+ } else {
+ return providerStateFactory.getInitialProviderStateFallback(orderSource, network);
+ }
+ },
+ getInitialProviderStateFromProvider: (
+ orderSource: OrderSource,
+ network: Network,
+ provider: Provider,
+ ): ProviderState => {
+ const providerState: ProviderState = {
+ 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/web3_wrapper.ts b/packages/instant/src/util/web3_wrapper.ts
deleted file mode 100644
index 24dcd9076..000000000
--- a/packages/instant/src/util/web3_wrapper.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Web3Wrapper } from '@0x/web3-wrapper';
-
-import { getProvider } from './provider';
-
-export const web3Wrapper = new Web3Wrapper(getProvider());