diff options
Diffstat (limited to 'packages/instant/src')
25 files changed, 447 insertions, 93 deletions
diff --git a/packages/instant/src/components/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx index 6fb8f60f0..6ad0e92e7 100644 --- a/packages/instant/src/components/erc20_asset_amount_input.tsx +++ b/packages/instant/src/components/erc20_asset_amount_input.tsx @@ -3,7 +3,7 @@ import * as _ from 'lodash'; import * as React from 'react'; import { ColorOption, transparentWhite } from '../style/theme'; -import { ERC20Asset } from '../types'; +import { ERC20Asset, SimpleHandler } from '../types'; import { assetUtils } from '../util/asset'; import { util } from '../util/util'; @@ -19,6 +19,7 @@ export interface ERC20AssetAmountInputProps { startingFontSizePx: number; fontColor?: ColorOption; isDisabled: boolean; + numberOfAssetsAvailable?: number; } export interface ERC20AssetAmountInputState { @@ -40,13 +41,14 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput const { asset } = this.props; return ( <Container whiteSpace="nowrap"> - {_.isUndefined(asset) ? this._renderBackupContent() : this._renderContentForAsset(asset)} + {_.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"> @@ -59,7 +61,6 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput /> </Container> <Container - cursor="pointer" display="inline-block" marginLeft="8px" title={assetUtils.bestNameForAsset(asset, undefined)} @@ -69,7 +70,7 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput fontSize={`${this.state.currentFontSizePx}px`} fontColor={ColorOption.white} textTransform="uppercase" - onClick={this._handleSymbolClick} + onClick={onSymbolClick} > {assetUtils.formattedSymbolForAsset(asset)} </Text> @@ -79,7 +80,14 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput </React.Fragment> ); }; - private readonly _renderBackupContent = (): React.ReactNode => { + 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 @@ -87,18 +95,21 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput fontColor={ColorOption.white} opacity={0.7} fontWeight="500" - onClick={this._handleSymbolClick} + onClick={this._generateSelectAssetClickHandler()} > - Select Token + {text} </Text> {this._renderChevronIcon()} </Flex> ); }; private readonly _renderChevronIcon = (): React.ReactNode => { + if (!this._areMultipleAssetsAvailable()) { + return null; + } return ( - <Container marginLeft="5px" onClick={this._handleSymbolClick}> - <Icon icon="chevron" width={12} /> + <Container marginLeft="5px"> + <Icon icon="chevron" width={12} onClick={this._handleSelectAssetClick} /> </Container> ); }; @@ -110,9 +121,22 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput currentFontSizePx: fontSizePx, }); }; - private readonly _handleSymbolClick = () => { + 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(this.props.asset); + this.props.onSelectAssetClick(); } }; // For assets with symbols of different length, 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..481778495 --- /dev/null +++ b/packages/instant/src/components/erc20_token_selector.tsx @@ -0,0 +1,107 @@ +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, Container, Flex, Text } from './ui'; + +export interface ERC20TokenSelectorProps { + tokens: ERC20Asset[]; + onTokenSelect: (token: ERC20Asset) => void; +} + +export interface ERC20TokenSelectorState { + searchQuery?: string; +} + +export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> { + public state: ERC20TokenSelectorState = { + searchQuery: undefined, + }; + public render(): React.ReactNode { + const { tokens, onTokenSelect } = this.props; + return ( + <Container> + <SearchInput + placeholder="Search tokens..." + width="100%" + value={this.state.searchQuery} + onChange={this._handleSearchInputChange} + /> + <Container overflow="scroll" height="275px" marginTop="10px"> + {_.map(tokens, token => { + if (!this._isTokenQueryMatch(token)) { + return null; + } + return <TokenSelectorRow key={token.assetData} token={token} onClick={onTokenSelect} />; + })} + </Container> + </Container> + ); + } + private readonly _handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + const searchQuery = event.target.value; + this.setState({ + searchQuery, + }); + }; + private readonly _isTokenQueryMatch = (token: ERC20Asset): boolean => { + const { searchQuery } = this.state; + if (_.isUndefined(searchQuery)) { + return true; + } + const stringToSearch = `${token.metaData.name} ${token.metaData.symbol}`; + return _.includes(stringToSearch.toLowerCase(), searchQuery.toLowerCase()); + }; +} + +interface TokenSelectorRowProps { + token: ERC20Asset; + onClick: (token: ERC20Asset) => void; +} + +class TokenSelectorRow extends React.Component<TokenSelectorRowProps> { + public render(): React.ReactNode { + const { token } = this.props; + const displaySymbol = assetUtils.bestNameForAsset(token); + return ( + <Container + padding="12px 0px" + borderBottom="1px solid" + borderColor={ColorOption.feintGrey} + backgroundColor={ColorOption.white} + width="100%" + onClick={this._handleClick} + darkenOnHover={true} + cursor="pointer" + > + <Container marginLeft="5px"> + <Flex justify="flex-start"> + <Container marginRight="10px"> + <Circle diameter={30} fillColor={token.metaData.primaryColor}> + <Flex height="100%"> + <Text fontColor={ColorOption.white} fontSize="8px"> + {displaySymbol} + </Text> + </Flex> + </Circle> + </Container> + <Text fontSize="14px" fontWeight={700} fontColor={ColorOption.black}> + {displaySymbol} + </Text> + <Container margin="0px 5px"> + <Text fontSize="14px"> - </Text> + </Container> + <Text fontSize="14px">{token.metaData.name}</Text> + </Flex> + </Container> + </Container> + ); + } + private readonly _handleClick = (): void => { + this.props.onClick(this.props.token); + }; +} diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index 19c08db70..80d7a3ee2 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -22,7 +22,7 @@ export interface InstantHeadingProps { const PLACEHOLDER_COLOR = ColorOption.white; const ICON_WIDTH = 34; const ICON_HEIGHT = 34; -const ICON_COLOR = 'white'; +const ICON_COLOR = ColorOption.white; export class InstantHeading extends React.Component<InstantHeadingProps, {}> { public render(): React.ReactNode { diff --git a/packages/instant/src/components/search_input.tsx b/packages/instant/src/components/search_input.tsx new file mode 100644 index 000000000..f082eaa16 --- /dev/null +++ b/packages/instant/src/components/search_input.tsx @@ -0,0 +1,26 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Container, Flex, Icon, Input, InputProps } from './ui'; + +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/sliding_panel.tsx b/packages/instant/src/components/sliding_panel.tsx index 9219ad1f1..ea1d6b9a1 100644 --- a/packages/instant/src/components/sliding_panel.tsx +++ b/packages/instant/src/components/sliding_panel.tsx @@ -5,18 +5,28 @@ import { zIndex } from '../style/z_index'; import { PositionAnimationSettings } from './animations/position_animation'; import { SlideAnimation, SlideAnimationState } from './animations/slide_animation'; -import { Button, Container, Text } from './ui'; +import { Container, Flex, Icon, Text } from './ui'; export interface PanelProps { + title?: string; onClose?: () => void; } -export const Panel: React.StatelessComponent<PanelProps> = ({ children, onClose }) => ( - <Container backgroundColor={ColorOption.white} width="100%" height="100%" zIndex={zIndex.panel}> - <Button onClick={onClose}> - <Text fontColor={ColorOption.white}>Close </Text> - </Button> - {children} +export const Panel: React.StatelessComponent<PanelProps> = ({ title, children, onClose }) => ( + <Container backgroundColor={ColorOption.white} width="100%" height="100%" zIndex={zIndex.panel} padding="20px"> + <Flex justify="space-between"> + {title && ( + <Container marginTop="3px"> + <Text fontColor={ColorOption.darkGrey} fontSize="18px" fontWeight="600" lineHeight="22px"> + {title} + </Text> + </Container> + )} + <Container position="relative" bottom="7px"> + <Icon width={12} color={ColorOption.lightGrey} icon="closeX" onClick={onClose} /> + </Container> + </Flex> + <Container marginTop="10px">{children}</Container> </Container> ); diff --git a/packages/instant/src/components/ui/circle.tsx b/packages/instant/src/components/ui/circle.tsx new file mode 100644 index 000000000..eec2777d2 --- /dev/null +++ b/packages/instant/src/components/ui/circle.tsx @@ -0,0 +1,22 @@ +import { styled } from '../../style/theme'; + +export interface CircleProps { + diameter: number; + fillColor?: string; +} + +export const Circle = + styled.div < + CircleProps > + ` + width: ${props => props.diameter}px; + height: ${props => props.diameter}px; + background-color: ${props => props.fillColor}; + border-radius: 50%; +`; + +Circle.displayName = 'Circle'; + +Circle.defaultProps = { + fillColor: 'white', +}; diff --git a/packages/instant/src/components/ui/container.tsx b/packages/instant/src/components/ui/container.tsx index 7b8642761..a0a187e5f 100644 --- a/packages/instant/src/components/ui/container.tsx +++ b/packages/instant/src/components/ui/container.tsx @@ -1,3 +1,5 @@ +import { darken } from 'polished'; + import { ColorOption, styled } from '../../style/theme'; import { cssRuleIfExists } from '../../style/util'; @@ -30,6 +32,7 @@ export interface ContainerProps { opacity?: number; cursor?: string; overflow?: string; + darkenOnHover?: boolean; } export const Container = @@ -64,6 +67,14 @@ export const Container = ${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')}; + &:hover { + ${props => + props.darkenOnHover + ? `background-color: ${ + props.backgroundColor ? darken(0.05, props.theme[props.backgroundColor]) : 'none' + }` + : ''}; + } `; Container.defaultProps = { diff --git a/packages/instant/src/components/ui/icon.tsx b/packages/instant/src/components/ui/icon.tsx index f12059cff..94ea26900 100644 --- a/packages/instant/src/components/ui/icon.tsx +++ b/packages/instant/src/components/ui/icon.tsx @@ -1,7 +1,7 @@ import * as _ from 'lodash'; import * as React from 'react'; -import { styled } from '../../style/theme'; +import { ColorOption, styled, Theme, withTheme } from '../../style/theme'; type svgRule = 'evenodd' | 'nonzero' | 'inherit'; interface IconInfo { @@ -20,6 +20,7 @@ interface IconInfoMapping { failed: IconInfo; success: IconInfo; chevron: IconInfo; + search: IconInfo; } const ICONS: IconInfoMapping = { closeX: { @@ -52,19 +53,28 @@ const ICONS: IconInfoMapping = { 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?: string; + color?: ColorOption; icon: keyof IconInfoMapping; onClick?: (event: React.MouseEvent<HTMLElement>) => void; padding?: string; + theme: Theme; } -const PlainIcon: 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]; return ( <div onClick={props.onClick} className={props.className}> <svg @@ -76,7 +86,7 @@ const PlainIcon: React.SFC<IconProps> = props => { > <path d={iconInfo.path} - fill={props.color} + fill={colorValue} fillRule={iconInfo.fillRule || 'nonzero'} clipRule={iconInfo.clipRule || 'nonzero'} stroke={iconInfo.stroke} @@ -90,7 +100,7 @@ const PlainIcon: React.SFC<IconProps> = props => { ); }; -export const Icon = styled(PlainIcon)` +export const Icon = withTheme(styled(PlainIcon)` cursor: ${props => (!_.isUndefined(props.onClick) ? 'pointer' : 'default')}; transition: opacity 0.5s ease; padding: ${props => props.padding}; @@ -101,10 +111,9 @@ export const Icon = styled(PlainIcon)` &:active { opacity: 1; } -`; +`); Icon.defaultProps = { - color: 'white', padding: '0em 0em', }; diff --git a/packages/instant/src/components/ui/index.ts b/packages/instant/src/components/ui/index.ts index 0efabdb85..87f5c11a1 100644 --- a/packages/instant/src/components/ui/index.ts +++ b/packages/instant/src/components/ui/index.ts @@ -1,4 +1,5 @@ export { Text, TextProps, Title } from './text'; +export { Circle, CircleProps } from './circle'; export { Button, ButtonProps } from './button'; export { Flex, FlexProps } from './flex'; export { Container, ContainerProps } from './container'; diff --git a/packages/instant/src/components/ui/overlay.tsx b/packages/instant/src/components/ui/overlay.tsx index c5258b031..f1706c874 100644 --- a/packages/instant/src/components/ui/overlay.tsx +++ b/packages/instant/src/components/ui/overlay.tsx @@ -1,7 +1,7 @@ import * as _ from 'lodash'; import * as React from 'react'; -import { overlayBlack, styled } from '../../style/theme'; +import { ColorOption, overlayBlack, styled } from '../../style/theme'; import { Container } from './container'; import { Flex } from './flex'; @@ -16,7 +16,7 @@ export interface OverlayProps { const PlainOverlay: React.StatelessComponent<OverlayProps> = ({ children, className, onClose }) => ( <Flex height="100vh" className={className}> <Container position="absolute" top="0px" right="0px"> - <Icon height={18} width={18} color="white" icon="closeX" onClick={onClose} padding="2em 2em" /> + <Icon height={18} width={18} color={ColorOption.white} icon="closeX" onClick={onClose} padding="2em 2em" /> </Container> <div>{children}</div> </Flex> diff --git a/packages/instant/src/components/ui/text.tsx b/packages/instant/src/components/ui/text.tsx index fd72f6cc8..cba6e7b20 100644 --- a/packages/instant/src/components/ui/text.tsx +++ b/packages/instant/src/components/ui/text.tsx @@ -18,7 +18,6 @@ export interface TextProps { fontWeight?: number | string; textDecorationLine?: string; onClick?: (event: React.MouseEvent<HTMLElement>) => void; - hoverColor?: string; noWrap?: boolean; display?: string; } @@ -46,9 +45,7 @@ export const Text = ${props => (props.textTransform ? `text-transform: ${props.textTransform}` : '')}; &:hover { ${props => - props.onClick - ? `color: ${props.hoverColor || darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` - : ''}; + props.onClick ? `color: ${darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` : ''}; } `; diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index f8e3935fb..c1bd17502 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { AvailableERC20TokenSelector } from '../containers/available_erc20_token_selector'; import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order_details'; import { LatestError } from '../containers/latest_error'; import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_buy_order_state_buttons'; @@ -45,10 +46,11 @@ export class ZeroExInstantContainer extends React.Component<ZeroExInstantContain </Container> </Flex> <SlidingPanel + title="Select Token" animationState={this.state.tokenSelectionPanelAnimationState} onClose={this._handlePanelClose} > - Select Your Token + <AvailableERC20TokenSelector onTokenSelect={this._handlePanelClose} /> </SlidingPanel> </Container> </Container> diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index fe4d37331..0b9408329 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -25,14 +25,14 @@ export type ZeroExInstantProviderProps = ZeroExInstantProviderRequiredProps & Partial<ZeroExInstantProviderOptionalProps>; export interface ZeroExInstantProviderRequiredProps { - // TODO: Change API when we allow the selection of different assetDatas - assetData: string; - liquiditySource: string | SignedOrder[]; + orderSource: string | SignedOrder[]; } export interface ZeroExInstantProviderOptionalProps { provider: Provider; + availableAssetDatas: string[]; defaultAssetBuyAmount: number; + defaultSelectedAssetData: string; additionalAssetMetaDataMap: ObjectMap<AssetMetaData>; networkId: Network; affiliateInfo: AffiliateInfo; @@ -40,6 +40,7 @@ export interface ZeroExInstantProviderOptionalProps { export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { private readonly _store: Store; + // TODO(fragosti): Write tests for this beast once we inject a provider. private static _mergeInitialStateWithProps(props: ZeroExInstantProviderProps, state: State = INITIAL_STATE): State { const networkId = props.networkId || state.network; // TODO: Proper wallet connect flow @@ -48,14 +49,14 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider networkId, }; let assetBuyer; - if (_.isString(props.liquiditySource)) { + if (_.isString(props.orderSource)) { assetBuyer = AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl( provider, - props.liquiditySource, + props.orderSource, assetBuyerOptions, ); } else { - assetBuyer = AssetBuyer.getAssetBuyerForProvidedOrders(provider, props.liquiditySource, assetBuyerOptions); + assetBuyer = AssetBuyer.getAssetBuyerForProvidedOrders(provider, props.orderSource, assetBuyerOptions); } const completeAssetMetaDataMap = { ...props.additionalAssetMetaDataMap, @@ -65,10 +66,19 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider ...state, assetBuyer, network: networkId, - selectedAsset: assetUtils.createAssetFromAssetData(props.assetData, completeAssetMetaDataMap, networkId), + selectedAsset: _.isUndefined(props.defaultSelectedAssetData) + ? undefined + : assetUtils.createAssetFromAssetDataOrThrow( + props.defaultSelectedAssetData, + completeAssetMetaDataMap, + networkId, + ), selectedAssetAmount: _.isUndefined(props.defaultAssetBuyAmount) ? state.selectedAssetAmount : new BigNumber(props.defaultAssetBuyAmount), + availableAssets: _.isUndefined(props.availableAssetDatas) + ? undefined + : assetUtils.createAssetsFromAssetDatas(props.availableAssetDatas, completeAssetMetaDataMap, networkId), assetMetaDataMap: completeAssetMetaDataMap, affiliateInfo: props.affiliateInfo, }; @@ -81,8 +91,14 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider } public componentDidMount(): void { + const state = this._store.getState(); // tslint:disable-next-line:no-floating-promises - asyncData.fetchAndDispatchToStore(this._store); + asyncData.fetchEthPriceAndDispatchToStore(this._store); + // fetch available assets if none are specified + if (_.isUndefined(state.availableAssets)) { + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAvailableAssetDatasAndDispatchToStore(this._store); + } // warm up the gas price estimator cache just in case we can't // grab the gas price estimate when submitting the transaction 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/selected_erc20_asset_amount_input.ts b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts index 12bc7040e..f7d56c564 100644 --- a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -27,6 +27,7 @@ interface ConnectedState { value?: BigNumber; asset?: ERC20Asset; isDisabled: boolean; + numberOfAssetsAvailable?: number; affiliateInfo?: AffiliateInfo; } @@ -44,6 +45,7 @@ interface ConnectedProps { asset?: ERC20Asset; onChange: (value?: BigNumber, asset?: ERC20Asset) => void; isDisabled: boolean; + numberOfAssetsAvailable?: number; } type FinalProps = ConnectedProps & SelectedERC20AssetAmountInputProps; @@ -52,20 +54,17 @@ const mapStateToProps = (state: State, _ownProps: SelectedERC20AssetAmountInputP const processState = state.buyOrderState.processState; const isEnabled = processState === OrderProcessState.NONE || processState === OrderProcessState.FAILURE; const isDisabled = !isEnabled; - - const selectedAsset = state.selectedAsset; - if (_.isUndefined(selectedAsset) || selectedAsset.metaData.assetProxyId !== AssetProxyId.ERC20) { - return { - value: state.selectedAssetAmount, - isDisabled, - }; - } - + 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; return { assetBuyer: state.assetBuyer, value: state.selectedAssetAmount, - asset: selectedAsset as ERC20Asset, + asset: selectedAsset, isDisabled, + numberOfAssetsAvailable, affiliateInfo: state.affiliateInfo, }; }; @@ -151,6 +150,7 @@ const mergeProps = ( connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset, connectedState.affiliateInfo); }, isDisabled: connectedState.isDisabled, + numberOfAssetsAvailable: connectedState.numberOfAssetsAvailable, }; }; diff --git a/packages/instant/src/data/asset_data_network_mapping.ts b/packages/instant/src/data/asset_data_network_mapping.ts index a7bc6a967..43bd34697 100644 --- a/packages/instant/src/data/asset_data_network_mapping.ts +++ b/packages/instant/src/data/asset_data_network_mapping.ts @@ -20,7 +20,7 @@ export const assetDataNetworkMapping: AssetDataByNetwork[] = [ }, // OMG { - [Network.Mainnet]: '0xf47261b000000000000000000000000042d6622dece394b54999fbd73d108123806f6a18', + [Network.Mainnet]: '0xf47261b0000000000000000000000000d26114cd6ee289accf82350c8d8487fedb8a0c07', [Network.Kovan]: '0xf47261b000000000000000000000000046096d8ec059dbaae2950b30e01634ff0dc652ec', }, // MKR @@ -59,6 +59,6 @@ export const assetDataNetworkMapping: AssetDataByNetwork[] = [ // REP { [Network.Kovan]: '0xf47261b00000000000000000000000008cb3971b8eb709c14616bd556ff6683019e90d9c', - [Network.Mainnet]: '0xf47261b0000000000000000000000000e94327d07fc17907b4db788e5adf2ed424addff6', + [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 d7cf2c0d8..8a0f29e21 100644 --- a/packages/instant/src/data/asset_meta_data_map.ts +++ b/packages/instant/src/data/asset_meta_data_map.ts @@ -10,65 +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': { + '0xf47261b0000000000000000000000000d26114cd6ee289accf82350c8d8487fedb8a0c07': { assetProxyId: AssetProxyId.ERC20, decimals: 18, primaryColor: '#2e61ea', symbol: 'omg', + name: 'OmiseGo', }, '0xf47261b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2': { assetProxyId: AssetProxyId.ERC20, decimals: 18, - primaryColor: 'white', + 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', }, - '0xf47261b0000000000000000000000000e94327d07fc17907b4db788e5adf2ed424addff6': { + '0xf47261b00000000000000000000000001985365e9f78359a9b6ad760e32412f4a445e862': { assetProxyId: AssetProxyId.ERC20, decimals: 18, primaryColor: '#512D80', symbol: 'rep', + name: 'Augur', }, }; diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index b269ad741..59d1e646f 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -7,8 +7,10 @@ import { ZeroExInstantOverlay, ZeroExInstantOverlayProps } from './index'; import { assert } from './util/assert'; export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFAULT_ZERO_EX_CONTAINER_SELECTOR) => { - assert.isHexString('props.assetData', props.assetData); - assert.isValidLiquiditySource('props.liquiditySource', props.liquiditySource); + assert.isValidOrderSource('orderSource', props.orderSource); + if (!_.isUndefined(props.defaultSelectedAssetData)) { + assert.isHexString('defaultSelectedAssetData', props.defaultSelectedAssetData); + } if (!_.isUndefined(props.additionalAssetMetaDataMap)) { assert.isValidAssetMetaDataMap('props.additionalAssetMetaDataMap', props.additionalAssetMetaDataMap); } @@ -18,6 +20,9 @@ export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFA 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); } diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index 9397dcc99..c41c5054b 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 } from '../types'; +import { ActionsUnion, Asset } from '../types'; export interface PlainAction<T extends string> { type: T; @@ -30,6 +30,7 @@ export enum ActionTypes { SET_BUY_ORDER_STATE_SUCCESS = 'SET_BUY_ORDER_STATE_SUCCESS', UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE', UPDATE_SELECTED_ASSET = 'UPDATE_SELECTED_ASSET', + SET_AVAILABLE_ASSETS = 'SET_AVAILABLE_ASSETS', SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING', SET_QUOTE_REQUEST_STATE_FAILURE = 'SET_QUOTE_REQUEST_STATE_FAILURE', SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE', @@ -48,7 +49,8 @@ export const actions = { 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), setErrorMessage: (errorMessage: string) => createAction(ActionTypes.SET_ERROR_MESSAGE, errorMessage), diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts index 4ed89bdc3..0e05c13da 100644 --- a/packages/instant/src/redux/async_data.ts +++ b/packages/instant/src/redux/async_data.ts @@ -1,22 +1,37 @@ +import * as _ from 'lodash'; + import { BIG_NUMBER_ZERO } from '../constants'; +import { assetUtils } from '../util/asset'; import { coinbaseApi } from '../util/coinbase_api'; +import { errorFlasher } from '../util/error_flasher'; -import { 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 { - ethUsdPrice = await coinbaseApi.getEthUsdPrice(); + const ethUsdPrice = await coinbaseApi.getEthUsdPrice(); + store.dispatch(actions.updateEthUsdPrice(ethUsdPrice)); } catch (e) { - // ignore - } finally { - store.dispatch({ - type: ActionTypes.UPDATE_ETH_USD_PRICE, - data: ethUsdPrice, - }); + const errorMessage = 'Error fetching ETH/USD price'; + errorFlasher.flashNewErrorMessage(store.dispatch, errorMessage); + store.dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + } + }, + fetchAvailableAssetDatasAndDispatchToStore: async (store: Store) => { + const { assetBuyer, assetMetaDataMap, network } = store.getState(); + if (!_.isUndefined(assetBuyer)) { + try { + const assetDatas = await assetBuyer.getAvailableAssetDatasAsync(); + const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, assetMetaDataMap, network); + store.dispatch(actions.setAvailableAssets(assets)); + } catch (e) { + const errorMessage = 'Could not find any assets'; + errorFlasher.flashNewErrorMessage(store.dispatch, errorMessage); + // On error, just specify that none are available + store.dispatch(actions.setAvailableAssets([])); + } } }, }; diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index 1b4fdabbc..8c1902bb8 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -24,6 +24,7 @@ export interface State { assetBuyer?: AssetBuyer; assetMetaDataMap: ObjectMap<AssetMetaData>; selectedAsset?: Asset; + availableAssets?: Asset[]; selectedAssetAmount?: BigNumber; buyOrderState: OrderState; ethUsdPrice?: BigNumber; @@ -37,6 +38,7 @@ export interface State { export const INITIAL_STATE: State = { network: Network.Mainnet, selectedAssetAmount: undefined, + availableAssets: undefined, assetMetaDataMap, buyOrderState: { processState: OrderProcessState.NONE }, ethUsdPrice: undefined, @@ -159,18 +161,9 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => latestErrorDisplayStatus: 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, - ); - } return { ...state, - selectedAsset: newSelectedAsset, + selectedAsset: action.data, }; case ActionTypes.RESET_AMOUNT: return { @@ -180,6 +173,11 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => buyOrderState: { processState: OrderProcessState.NONE }, selectedAssetAmount: undefined, }; + case ActionTypes.SET_AVAILABLE_ASSETS: + return { + ...state, + availableAssets: action.data, + }; default: return state; } diff --git a/packages/instant/src/style/theme.ts b/packages/instant/src/style/theme.ts index e81694620..d10c9b72c 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, keyframes, ThemeProvider } = styledComponents; +const { default: styled, css, keyframes, withTheme, ThemeProvider } = styledComponents; export type Theme = { [key in ColorOption]: string }; @@ -10,6 +10,7 @@ export enum ColorOption { lightGrey = 'lightGrey', grey = 'grey', feintGrey = 'feintGrey', + lightestGrey = 'lightestGrey', darkGrey = 'darkGrey', white = 'white', lightOrange = 'lightOrange', @@ -17,11 +18,12 @@ export enum ColorOption { } export const theme: Theme = { - primaryColor: '#512D80', + primaryColor: '#333', black: 'black', lightGrey: '#999999', grey: '#666666', feintGrey: '#DEDEDE', + lightestGrey: '#EEEEEE', darkGrey: '#333333', white: 'white', lightOrange: '#F9F2ED', @@ -31,4 +33,4 @@ export const theme: Theme = { export const transparentWhite = 'rgba(255,255,255,0.3)'; export const overlayBlack = 'rgba(0, 0, 0, 0.6)'; -export { styled, css, keyframes, ThemeProvider }; +export { styled, css, keyframes, withTheme, ThemeProvider }; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 834127994..449bc0f31 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -46,6 +46,7 @@ export interface ERC20AssetMetaData { decimals: number; primaryColor?: string; symbol: string; + name: string; } export interface ERC721AssetMetaData { @@ -82,6 +83,8 @@ export enum ZeroExInstantError { InsufficientETH = 'INSUFFICIENT_ETH', } +export type SimpleHandler = () => void; + export interface AffiliateInfo { feeRecipient: string; feePercentage: number; diff --git a/packages/instant/src/util/assert.ts b/packages/instant/src/util/assert.ts index 99e177993..971c1eb96 100644 --- a/packages/instant/src/util/assert.ts +++ b/packages/instant/src/util/assert.ts @@ -8,12 +8,15 @@ import { AffiliateInfo, AssetMetaData } from '../types'; export const assert = { ...sharedAssert, - isValidLiquiditySource(variableName: string, liquiditySource: string | SignedOrder[]): void { - if (_.isString(liquiditySource)) { - sharedAssert.isUri(variableName, liquiditySource); + isValidOrderSource(variableName: string, orderSource: string | SignedOrder[]): void { + if (_.isString(orderSource)) { + sharedAssert.isUri(variableName, orderSource); return; } - sharedAssert.doesConformToSchema(variableName, liquiditySource, schemas.signedOrdersSchema); + 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) => { diff --git a/packages/instant/src/util/asset.ts b/packages/instant/src/util/asset.ts index 630103c7b..fbfbb19f3 100644 --- a/packages/instant/src/util/asset.ts +++ b/packages/instant/src/util/asset.ts @@ -5,7 +5,31 @@ import { assetDataNetworkMapping } from '../data/asset_data_network_mapping'; 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,19 +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) { - const mainnetAssetDataIfExists = 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; }, @@ -63,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); + }, }; |