diff options
Diffstat (limited to 'packages/instant/src/components')
34 files changed, 675 insertions, 344 deletions
diff --git a/packages/instant/src/components/amount_placeholder.tsx b/packages/instant/src/components/amount_placeholder.tsx index 29ce8fafb..290e34a07 100644 --- a/packages/instant/src/components/amount_placeholder.tsx +++ b/packages/instant/src/components/amount_placeholder.tsx @@ -30,3 +30,5 @@ export const AmountPlaceholder: React.StatelessComponent<AmountPlaceholderProps> return <PlainPlaceholder color={props.color} />; } }; + +AmountPlaceholder.displayName = 'AmountPlaceholder'; diff --git a/packages/instant/src/components/animations/slide_animation.tsx b/packages/instant/src/components/animations/slide_animation.tsx index 5992bcba7..6ac47e9a6 100644 --- a/packages/instant/src/components/animations/slide_animation.tsx +++ b/packages/instant/src/components/animations/slide_animation.tsx @@ -11,6 +11,7 @@ export interface SlideAnimationProps { slideOutSettings: OptionallyScreenSpecific<PositionAnimationSettings>; zIndex?: OptionallyScreenSpecific<number>; height?: string; + onAnimationEnd?: () => void; } export const SlideAnimation: React.StatelessComponent<SlideAnimationProps> = props => { @@ -19,8 +20,15 @@ export const SlideAnimation: React.StatelessComponent<SlideAnimationProps> = pro } const positionSettings = props.animationState === 'slidIn' ? props.slideInSettings : props.slideOutSettings; return ( - <PositionAnimation height={props.height} positionSettings={positionSettings} zIndex={props.zIndex}> + <PositionAnimation + onAnimationEnd={props.onAnimationEnd} + height={props.height} + positionSettings={positionSettings} + zIndex={props.zIndex} + > {props.children} </PositionAnimation> ); }; + +SlideAnimation.displayName = 'SlideAnimation'; diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx index 8b6121e43..551e857a5 100644 --- a/packages/instant/src/components/buy_button.tsx +++ b/packages/instant/src/components/buy_button.tsx @@ -1,4 +1,5 @@ import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; @@ -7,7 +8,9 @@ import { oc } from 'ts-optchain'; import { WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX } from '../constants'; import { ColorOption } from '../style/theme'; -import { AffiliateInfo, ZeroExInstantError } from '../types'; +import { AffiliateInfo, Asset, ZeroExInstantError } from '../types'; +import { analytics } from '../util/analytics'; +import { errorReporter } from '../util/error_reporter'; import { gasPriceEstimator } from '../util/gas_price_estimator'; import { util } from '../util/util'; @@ -20,6 +23,7 @@ export interface BuyButtonProps { assetBuyer: AssetBuyer; web3Wrapper: Web3Wrapper; affiliateInfo?: AffiliateInfo; + selectedAsset?: Asset; onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; onSignatureDenied: (buyQuote: BuyQuote) => void; @@ -28,15 +32,19 @@ export interface BuyButtonProps { onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; } -export class BuyButton extends React.Component<BuyButtonProps> { +export class BuyButton extends React.PureComponent<BuyButtonProps> { public static defaultProps = { onClick: util.boundNoop, onBuySuccess: util.boundNoop, onBuyFailure: util.boundNoop, }; public render(): React.ReactNode { - const { buyQuote, accountAddress } = this.props; + const { buyQuote, accountAddress, selectedAsset } = this.props; const shouldDisableButton = _.isUndefined(buyQuote) || _.isUndefined(accountAddress); + const buttonText = + !_.isUndefined(selectedAsset) && selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20 + ? `Buy ${selectedAsset.metaData.symbol.toUpperCase()}` + : 'Buy Now'; return ( <Button width="100%" @@ -44,7 +52,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { isDisabled={shouldDisableButton} fontColor={ColorOption.white} > - Buy + {buttonText} </Button> ); } @@ -59,6 +67,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { // if we don't have a balance for the user, let the transaction through, it will be handled by the wallet const hasSufficientEth = _.isUndefined(accountEthBalanceInWei) || accountEthBalanceInWei.gte(ethNeededForBuy); if (!hasSufficientEth) { + analytics.trackBuyNotEnoughEth(buyQuote); this.props.onValidationFail(buyQuote, ZeroExInstantError.InsufficientETH); return; } @@ -66,6 +75,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { const gasInfo = await gasPriceEstimator.getGasInfoAsync(); const feeRecipient = oc(affiliateInfo).feeRecipient(); try { + analytics.trackBuyStarted(buyQuote); txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { feeRecipient, takerAddress: accountAddress, @@ -73,11 +83,18 @@ export class BuyButton extends React.Component<BuyButtonProps> { }); } catch (e) { if (e instanceof Error) { - if (e.message === AssetBuyerError.SignatureRequestDenied) { + if (e.message === AssetBuyerError.TransactionValueTooLow) { + analytics.trackBuySimulationFailed(buyQuote); + this.props.onValidationFail(buyQuote, AssetBuyerError.TransactionValueTooLow); + return; + } else if (e.message === AssetBuyerError.SignatureRequestDenied) { + analytics.trackBuySignatureDenied(buyQuote); this.props.onSignatureDenied(buyQuote); return; - } else if (e.message === AssetBuyerError.TransactionValueTooLow) { - this.props.onValidationFail(buyQuote, AssetBuyerError.TransactionValueTooLow); + } else { + errorReporter.report(e); + analytics.trackBuyUnknownError(buyQuote, e.message); + this.props.onValidationFail(buyQuote, ZeroExInstantError.CouldNotSubmitTransaction); return; } } @@ -87,14 +104,17 @@ export class BuyButton extends React.Component<BuyButtonProps> { const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs; this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); try { + analytics.trackBuyTxSubmitted(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); await web3Wrapper.awaitTransactionSuccessAsync(txHash); } catch (e) { if (e instanceof Error && e.message.startsWith(WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX)) { + analytics.trackBuyTxFailed(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); this.props.onBuyFailure(buyQuote, txHash); return; } throw e; } + analytics.trackBuyTxSucceeded(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); this.props.onBuySuccess(buyQuote, txHash); }; } diff --git a/packages/instant/src/components/buy_order_progress.tsx b/packages/instant/src/components/buy_order_progress.tsx index 6568de91b..11ac5d5e0 100644 --- a/packages/instant/src/components/buy_order_progress.tsx +++ b/packages/instant/src/components/buy_order_progress.tsx @@ -21,7 +21,7 @@ export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = const hasEnded = buyOrderState.processState !== OrderProcessState.Processing; const expectedTimeMs = progress.expectedEndTimeUnix - progress.startTimeUnix; return ( - <Container padding="20px 20px 0px 20px" width="100%"> + <Container width="100%" padding="20px 20px 0px 20px"> <Container marginBottom="5px"> <TimeCounter estimatedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> </Container> @@ -31,3 +31,5 @@ export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = } return null; }; + +BuyOrderProgress.displayName = 'BuyOrderProgress'; diff --git a/packages/instant/src/components/buy_order_state_buttons.tsx b/packages/instant/src/components/buy_order_state_buttons.tsx index e563bec73..1214559d1 100644 --- a/packages/instant/src/components/buy_order_state_buttons.tsx +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -4,7 +4,7 @@ import { Web3Wrapper } from '@0x/web3-wrapper'; import * as React from 'react'; import { ColorOption } from '../style/theme'; -import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; +import { AffiliateInfo, Asset, OrderProcessState, ZeroExInstantError } from '../types'; import { BuyButton } from './buy_button'; import { PlacingOrderButton } from './placing_order_button'; @@ -21,6 +21,7 @@ export interface BuyOrderStateButtonProps { assetBuyer: AssetBuyer; web3Wrapper: Web3Wrapper; affiliateInfo?: AffiliateInfo; + selectedAsset?: Asset; onViewTransaction: () => void; onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; @@ -60,6 +61,7 @@ export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonP assetBuyer={props.assetBuyer} web3Wrapper={props.web3Wrapper} affiliateInfo={props.affiliateInfo} + selectedAsset={props.selectedAsset} onValidationPending={props.onValidationPending} onValidationFail={props.onValidationFail} onSignatureDenied={props.onSignatureDenied} @@ -69,3 +71,5 @@ export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonP /> ); }; + +BuyOrderStateButtons.displayName = 'BuyOrderStateButtons'; diff --git a/packages/instant/src/components/erc20_asset_amount_input.tsx b/packages/instant/src/components/erc20_asset_amount_input.tsx index b825255c4..0418f9165 100644 --- a/packages/instant/src/components/erc20_asset_amount_input.tsx +++ b/packages/instant/src/components/erc20_asset_amount_input.tsx @@ -31,7 +31,7 @@ export interface ERC20AssetAmountInputState { currentFontSizePx: number; } -export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInputProps, ERC20AssetAmountInputState> { +export class ERC20AssetAmountInput extends React.PureComponent<ERC20AssetAmountInputProps, ERC20AssetAmountInputState> { public static defaultProps = { onChange: util.boundNoop, isDisabled: false, @@ -64,6 +64,9 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput maxFontSizePx={this.props.startingFontSizePx} onAmountChange={this._handleChange} onFontSizeChange={this._handleFontSizeChange} + hasAutofocus={true} + /* We send in a key of asset data to force a rerender of this component when the user selects a new asset. We do this so the autofocus attribute will bring focus onto this input */ + key={asset.assetData} /> </Container> <Container @@ -110,7 +113,7 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput ); }; private readonly _renderChevronIcon = (): React.ReactNode => { - if (!this._areMultipleAssetsAvailable()) { + if (!this._areAnyAssetsAvailable()) { return null; } return ( @@ -131,14 +134,14 @@ export class ERC20AssetAmountInput extends React.Component<ERC20AssetAmountInput // We don't want to allow opening the token selection panel if there are no assets. // Since styles are inferred from the presence of a click handler, we want to return undefined // instead of providing a noop. - if (!this._areMultipleAssetsAvailable() || _.isUndefined(this.props.onSelectAssetClick)) { + if (!this._areAnyAssetsAvailable() || _.isUndefined(this.props.onSelectAssetClick)) { return undefined; } return this._handleSelectAssetClick; }; - private readonly _areMultipleAssetsAvailable = (): boolean => { + private readonly _areAnyAssetsAvailable = (): boolean => { const { numberOfAssetsAvailable } = this.props; - return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 1; + return !_.isUndefined(numberOfAssetsAvailable) && numberOfAssetsAvailable > 0; }; private readonly _handleSelectAssetClick = (): void => { if (this.props.onSelectAssetClick) { diff --git a/packages/instant/src/components/erc20_token_selector.tsx b/packages/instant/src/components/erc20_token_selector.tsx index 1b1921acb..a26fb5cf5 100644 --- a/packages/instant/src/components/erc20_token_selector.tsx +++ b/packages/instant/src/components/erc20_token_selector.tsx @@ -3,10 +3,10 @@ import * as React from 'react'; import { ColorOption } from '../style/theme'; import { ERC20Asset } from '../types'; +import { analytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { SearchInput } from './search_input'; - import { Circle } from './ui/circle'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; @@ -18,15 +18,15 @@ export interface ERC20TokenSelectorProps { } export interface ERC20TokenSelectorState { - searchQuery?: string; + searchQuery: string; } -export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> { +export class ERC20TokenSelector extends React.PureComponent<ERC20TokenSelectorProps> { public state: ERC20TokenSelectorState = { - searchQuery: undefined, + searchQuery: '', }; public render(): React.ReactNode { - const { tokens, onTokenSelect } = this.props; + const { tokens } = this.props; return ( <Container height="100%"> <Container marginBottom="10px"> @@ -42,12 +42,11 @@ export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> tabIndex={-1} /> <Container overflow="scroll" height="calc(100% - 90px)" marginTop="10px"> - {_.map(tokens, token => { - if (!this._isTokenQueryMatch(token)) { - return null; - } - return <TokenSelectorRow key={token.assetData} token={token} onClick={onTokenSelect} />; - })} + <TokenRowFilter + tokens={tokens} + onClick={this._handleTokenClick} + searchQuery={this.state.searchQuery} + /> </Container> </Container> ); @@ -57,13 +56,38 @@ export class ERC20TokenSelector extends React.Component<ERC20TokenSelectorProps> this.setState({ searchQuery, }); + analytics.trackTokenSelectorSearched(searchQuery); + }; + private readonly _handleTokenClick = (token: ERC20Asset): void => { + this.props.onTokenSelect(token); }; +} + +interface TokenRowFilterProps { + tokens: ERC20Asset[]; + onClick: (token: ERC20Asset) => void; + searchQuery: string; +} + +class TokenRowFilter extends React.Component<TokenRowFilterProps> { + public render(): React.ReactNode { + return _.map(this.props.tokens, token => { + if (!this._isTokenQueryMatch(token)) { + return null; + } + return <TokenSelectorRow key={token.assetData} token={token} onClick={this.props.onClick} />; + }); + } + public shouldComponentUpdate(nextProps: TokenRowFilterProps): boolean { + const arePropsDeeplyEqual = _.isEqual(nextProps, this.props); + return !arePropsDeeplyEqual; + } private readonly _isTokenQueryMatch = (token: ERC20Asset): boolean => { - const { searchQuery } = this.state; - if (_.isUndefined(searchQuery)) { + const { searchQuery } = this.props; + const searchQueryLowerCase = searchQuery.toLowerCase().trim(); + if (searchQueryLowerCase === '') { return true; } - const searchQueryLowerCase = searchQuery.toLowerCase(); const tokenName = token.metaData.name.toLowerCase(); const tokenSymbol = token.metaData.symbol.toLowerCase(); return _.startsWith(tokenSymbol, searchQueryLowerCase) || _.startsWith(tokenName, searchQueryLowerCase); @@ -75,7 +99,7 @@ interface TokenSelectorRowProps { onClick: (token: ERC20Asset) => void; } -class TokenSelectorRow extends React.Component<TokenSelectorRowProps> { +class TokenSelectorRow extends React.PureComponent<TokenSelectorRowProps> { public render(): React.ReactNode { const { token } = this.props; const circleColor = token.metaData.primaryColor || 'black'; @@ -121,20 +145,32 @@ interface TokenSelectorRowIconProps { token: ERC20Asset; } -const TokenSelectorRowIcon: React.StatelessComponent<TokenSelectorRowIconProps> = props => { - const { token } = props; - const iconUrlIfExists = token.metaData.iconUrl; - const TokenIcon = require(`../assets/icons/${token.metaData.symbol}.svg`); - const displaySymbol = assetUtils.bestNameForAsset(token); - if (!_.isUndefined(iconUrlIfExists)) { - return <img src={iconUrlIfExists} />; - } else if (!_.isUndefined(TokenIcon)) { - return <TokenIcon />; - } else { - return ( - <Text fontColor={ColorOption.white} fontSize="8px"> - {displaySymbol} - </Text> - ); +const getTokenIcon = (symbol: string): React.StatelessComponent | undefined => { + try { + return require(`../assets/icons/${symbol}.svg`) as React.StatelessComponent; + } catch (e) { + // Can't find icon + return undefined; } }; + +class TokenSelectorRowIcon extends React.PureComponent<TokenSelectorRowIconProps> { + public render(): React.ReactNode { + const { token } = this.props; + const iconUrlIfExists = token.metaData.iconUrl; + + const TokenIcon = getTokenIcon(token.metaData.symbol); + const displaySymbol = assetUtils.bestNameForAsset(token); + if (!_.isUndefined(iconUrlIfExists)) { + return <img src={iconUrlIfExists} />; + } else if (!_.isUndefined(TokenIcon)) { + return <TokenIcon />; + } else { + return ( + <Text fontColor={ColorOption.white} fontSize="8px"> + {displaySymbol} + </Text> + ); + } + } +} diff --git a/packages/instant/src/components/install_wallet_panel_content.tsx b/packages/instant/src/components/install_wallet_panel_content.tsx index 88c26f59c..1af3dafbb 100644 --- a/packages/instant/src/components/install_wallet_panel_content.tsx +++ b/packages/instant/src/components/install_wallet_panel_content.tsx @@ -8,7 +8,9 @@ import { } from '../constants'; import { ColorOption } from '../style/theme'; import { Browser } from '../types'; +import { analytics } from '../util/analytics'; import { envUtil } from '../util/env'; +import { util } from '../util/util'; import { MetaMaskLogo } from './meta_mask_logo'; import { StandardPanelContent, StandardPanelContentProps } from './standard_panel_content'; @@ -16,7 +18,7 @@ import { Button } from './ui/button'; export interface InstallWalletPanelContentProps {} -export class InstallWalletPanelContent extends React.Component<InstallWalletPanelContentProps> { +export class InstallWalletPanelContent extends React.PureComponent<InstallWalletPanelContentProps> { public render(): React.ReactNode { const panelProps = this._getStandardPanelContentProps(); return <StandardPanelContent {...panelProps} />; @@ -45,6 +47,10 @@ export class InstallWalletPanelContent extends React.Component<InstallWalletPane default: break; } + const onActionClick = () => { + analytics.trackInstallWalletModalClickedGet(); + util.createOpenUrlInNewWindow(actionUrl)(); + }; return { image: <MetaMaskLogo width={85} height={80} />, title: 'Install MetaMask', @@ -52,10 +58,11 @@ export class InstallWalletPanelContent extends React.Component<InstallWalletPane moreInfoSettings: { href: META_MASK_SITE_URL, text: 'What is MetaMask?', + onClick: analytics.trackInstallWalletModalClickedExplanation, }, action: ( <Button - href={actionUrl} + onClick={onActionClick} width="100%" fontColor={ColorOption.white} backgroundColor={ColorOption.darkOrange} diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index 002695269..e943f68d7 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -28,11 +28,11 @@ const ICON_WIDTH = 34; const ICON_HEIGHT = 34; const ICON_COLOR = ColorOption.white; -export class InstantHeading extends React.Component<InstantHeadingProps, {}> { +export class InstantHeading extends React.PureComponent<InstantHeadingProps, {}> { public render(): React.ReactNode { const iconOrAmounts = this._renderIcon() || this._renderAmountsSection(); return ( - <Container backgroundColor={ColorOption.primaryColor} padding="20px" width="100%"> + <Container backgroundColor={ColorOption.primaryColor} width="100%" padding="20px"> <Container marginBottom="5px"> <Text letterSpacing="1px" @@ -61,12 +61,19 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { } private _renderAmountsSection(): React.ReactNode { - return ( - <Container> - <Container marginBottom="5px">{this._renderPlaceholderOrAmount(this._renderEthAmount)}</Container> - <Container opacity={0.7}>{this._renderPlaceholderOrAmount(this._renderDollarAmount)}</Container> - </Container> - ); + if ( + _.isUndefined(this.props.totalEthBaseUnitAmount) && + this.props.quoteRequestState !== AsyncProcessState.Pending + ) { + return null; + } else { + return ( + <Container> + <Container marginBottom="5px">{this._renderPlaceholderOrAmount(this._renderEthAmount)}</Container> + <Container opacity={0.7}>{this._renderPlaceholderOrAmount(this._renderDollarAmount)}</Container> + </Container> + ); + } } private _renderIcon(): React.ReactNode { @@ -106,20 +113,30 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { } private readonly _renderEthAmount = (): React.ReactNode => { + const ethAmount = format.ethBaseUnitAmount( + this.props.totalEthBaseUnitAmount, + 4, + <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, + ); + + const fontSize = _.isString(ethAmount) && ethAmount.length >= 13 ? '14px' : '16px'; return ( - <Text fontSize="16px" fontColor={ColorOption.white} fontWeight={500}> - {format.ethBaseUnitAmount( - this.props.totalEthBaseUnitAmount, - 4, - <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, - )} + <Text + fontSize={fontSize} + textAlign="right" + width="100%" + fontColor={ColorOption.white} + fontWeight={500} + noWrap={true} + > + {ethAmount} </Text> ); }; private readonly _renderDollarAmount = (): React.ReactNode => { return ( - <Text fontSize="16px" fontColor={ColorOption.white}> + <Text fontSize="16px" textAlign="right" width="100%" fontColor={ColorOption.white} noWrap={true}> {format.ethBaseUnitAmountInUsd( this.props.totalEthBaseUnitAmount, this.props.ethUsdPrice, diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx index 5fc956e1c..4db20b13e 100644 --- a/packages/instant/src/components/order_details.tsx +++ b/packages/instant/src/components/order_details.tsx @@ -4,124 +4,227 @@ import * as _ from 'lodash'; import * as React from 'react'; import { oc } from 'ts-optchain'; -import { BIG_NUMBER_ZERO } from '../constants'; +import { BIG_NUMBER_ZERO, DEFAULT_UNKOWN_ASSET_NAME } from '../constants'; import { ColorOption } from '../style/theme'; +import { BaseCurrency } from '../types'; import { format } from '../util/format'; import { AmountPlaceholder } from './amount_placeholder'; +import { SectionHeader } from './section_header'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; -import { Text } from './ui/text'; +import { Text, TextProps } from './ui/text'; export interface OrderDetailsProps { buyQuoteInfo?: BuyQuoteInfo; selectedAssetUnitAmount?: BigNumber; ethUsdPrice?: BigNumber; isLoading: boolean; + assetName?: string; + baseCurrency: BaseCurrency; + onBaseCurrencySwitchEth: () => void; + onBaseCurrencySwitchUsd: () => void; } -export class OrderDetails extends React.Component<OrderDetailsProps> { +export class OrderDetails extends React.PureComponent<OrderDetailsProps> { public render(): React.ReactNode { - const { buyQuoteInfo, ethUsdPrice, selectedAssetUnitAmount } = this.props; - const buyQuoteAccessor = oc(buyQuoteInfo); - const assetEthBaseUnitAmount = buyQuoteAccessor.assetEthAmount(); - const feeEthBaseUnitAmount = buyQuoteAccessor.feeEthAmount(); - const totalEthBaseUnitAmount = buyQuoteAccessor.totalEthAmount(); - const pricePerTokenEth = - !_.isUndefined(assetEthBaseUnitAmount) && - !_.isUndefined(selectedAssetUnitAmount) && - !selectedAssetUnitAmount.eq(BIG_NUMBER_ZERO) - ? assetEthBaseUnitAmount.div(selectedAssetUnitAmount).ceil() - : undefined; + const shouldShowUsdError = this.props.baseCurrency === BaseCurrency.USD && this._hadErrorFetchingUsdPrice(); return ( - <Container padding="20px" width="100%" flexGrow={1}> - <Container marginBottom="10px"> - <Text - letterSpacing="1px" - fontColor={ColorOption.primaryColor} - fontWeight={600} - textTransform="uppercase" - fontSize="14px" - > - Order Details - </Text> - </Container> - <EthAmountRow - rowLabel="Token Price" - ethAmount={pricePerTokenEth} - ethUsdPrice={ethUsdPrice} - isLoading={this.props.isLoading} + <Container width="100%" flexGrow={1} padding="20px 20px 0px 20px"> + <Container marginBottom="10px">{this._renderHeader()}</Container> + {shouldShowUsdError ? this._renderErrorFetchingUsdPrice() : this._renderRows()} + </Container> + ); + } + + private _renderRows(): React.ReactNode { + const { buyQuoteInfo } = this.props; + return ( + <React.Fragment> + <OrderDetailsRow + labelText={this._assetAmountLabel()} + primaryValue={this._displayAmountOrPlaceholder(buyQuoteInfo && buyQuoteInfo.assetEthAmount)} /> - <EthAmountRow - rowLabel="Fee" - ethAmount={feeEthBaseUnitAmount} - ethUsdPrice={ethUsdPrice} - isLoading={this.props.isLoading} + <OrderDetailsRow + labelText="Fee" + primaryValue={this._displayAmountOrPlaceholder(buyQuoteInfo && buyQuoteInfo.feeEthAmount)} /> - <EthAmountRow - rowLabel="Total Cost" - ethAmount={totalEthBaseUnitAmount} - ethUsdPrice={ethUsdPrice} - shouldEmphasize={true} - isLoading={this.props.isLoading} + <OrderDetailsRow + labelText="Total Cost" + isLabelBold={true} + primaryValue={this._displayAmountOrPlaceholder(buyQuoteInfo && buyQuoteInfo.totalEthAmount)} + isPrimaryValueBold={true} + secondaryValue={this._totalCostSecondaryValue()} /> - </Container> + </React.Fragment> ); } -} -export interface EthAmountRowProps { - rowLabel: string; - ethAmount?: BigNumber; - isEthAmountInBaseUnits?: boolean; - ethUsdPrice?: BigNumber; - shouldEmphasize?: boolean; - isLoading: boolean; + private _renderErrorFetchingUsdPrice(): React.ReactNode { + return ( + <Text> + There was an error fetching the USD price. + <Text + onClick={this.props.onBaseCurrencySwitchEth} + fontWeight={700} + fontColor={ColorOption.primaryColor} + > + Click here + </Text> + {' to view ETH prices'} + </Text> + ); + } + + private _hadErrorFetchingUsdPrice(): boolean { + return this.props.ethUsdPrice ? this.props.ethUsdPrice.equals(BIG_NUMBER_ZERO) : false; + } + + private _totalCostSecondaryValue(): React.ReactNode { + const secondaryCurrency = this.props.baseCurrency === BaseCurrency.USD ? BaseCurrency.ETH : BaseCurrency.USD; + + const canDisplayCurrency = + secondaryCurrency === BaseCurrency.ETH || + (secondaryCurrency === BaseCurrency.USD && this.props.ethUsdPrice && !this._hadErrorFetchingUsdPrice()); + + if (this.props.buyQuoteInfo && canDisplayCurrency) { + return this._displayAmount(secondaryCurrency, this.props.buyQuoteInfo.totalEthAmount); + } else { + return undefined; + } + } + + private _displayAmountOrPlaceholder(weiAmount?: BigNumber): React.ReactNode { + const { baseCurrency, isLoading } = this.props; + + if (_.isUndefined(weiAmount)) { + return ( + <Container opacity={0.5}> + <AmountPlaceholder color={ColorOption.lightGrey} isPulsating={isLoading} /> + </Container> + ); + } + + return this._displayAmount(baseCurrency, weiAmount); + } + + private _displayAmount(currency: BaseCurrency, weiAmount: BigNumber): React.ReactNode { + switch (currency) { + case BaseCurrency.USD: + return format.ethBaseUnitAmountInUsd(weiAmount, this.props.ethUsdPrice, 2, ''); + case BaseCurrency.ETH: + return format.ethBaseUnitAmount(weiAmount, 4, ''); + } + } + + private _assetAmountLabel(): React.ReactNode { + const { assetName, baseCurrency } = this.props; + const numTokens = this.props.selectedAssetUnitAmount; + + // Display as 0 if we have a selected asset + const displayNumTokens = + assetName && assetName !== DEFAULT_UNKOWN_ASSET_NAME && _.isUndefined(numTokens) + ? new BigNumber(0) + : numTokens; + if (!_.isUndefined(displayNumTokens)) { + let numTokensWithSymbol: React.ReactNode = displayNumTokens.toString(); + if (assetName) { + numTokensWithSymbol += ` ${assetName}`; + } + const pricePerTokenWei = this._pricePerTokenWei(); + if (pricePerTokenWei) { + const atPriceDisplay = ( + <Text fontColor={ColorOption.lightGrey}> + @ {this._displayAmount(baseCurrency, pricePerTokenWei)} + </Text> + ); + numTokensWithSymbol = ( + <React.Fragment> + {numTokensWithSymbol} {atPriceDisplay} + </React.Fragment> + ); + } + return numTokensWithSymbol; + } + return 'Token Amount'; + } + + private _pricePerTokenWei(): BigNumber | undefined { + const buyQuoteAccessor = oc(this.props.buyQuoteInfo); + const assetTotalInWei = buyQuoteAccessor.assetEthAmount(); + const selectedAssetUnitAmount = this.props.selectedAssetUnitAmount; + return !_.isUndefined(assetTotalInWei) && + !_.isUndefined(selectedAssetUnitAmount) && + !selectedAssetUnitAmount.eq(BIG_NUMBER_ZERO) + ? assetTotalInWei.div(selectedAssetUnitAmount).ceil() + : undefined; + } + + private _baseCurrencyChoice(choice: BaseCurrency): React.ReactNode { + const onClick = + choice === BaseCurrency.ETH ? this.props.onBaseCurrencySwitchEth : this.props.onBaseCurrencySwitchUsd; + const isSelected = this.props.baseCurrency === choice; + + const textStyle: TextProps = { onClick, fontSize: '12px' }; + if (isSelected) { + textStyle.fontColor = ColorOption.primaryColor; + textStyle.fontWeight = 700; + } else { + textStyle.fontColor = ColorOption.lightGrey; + } + return <Text {...textStyle}>{choice}</Text>; + } + + private _renderHeader(): React.ReactNode { + return ( + <Flex justify="space-between"> + <SectionHeader>Order Details</SectionHeader> + <Container> + {this._baseCurrencyChoice(BaseCurrency.ETH)} + <Container marginLeft="5px" marginRight="5px" display="inline"> + <Text fontSize="12px" fontColor={ColorOption.feintGrey}> + / + </Text> + </Container> + {this._baseCurrencyChoice(BaseCurrency.USD)} + </Container> + </Flex> + ); + } } -export class EthAmountRow extends React.Component<EthAmountRowProps> { - public static defaultProps = { - shouldEmphasize: false, - isEthAmountInBaseUnits: true, - }; +export interface OrderDetailsRowProps { + labelText: React.ReactNode; + isLabelBold?: boolean; + isPrimaryValueBold?: boolean; + primaryValue: React.ReactNode; + secondaryValue?: React.ReactNode; +} +export class OrderDetailsRow extends React.PureComponent<OrderDetailsRowProps, {}> { public render(): React.ReactNode { - const { rowLabel, ethAmount, isEthAmountInBaseUnits, shouldEmphasize, isLoading } = this.props; - - const fontWeight = shouldEmphasize ? 700 : 400; - const ethFormatter = isEthAmountInBaseUnits ? format.ethBaseUnitAmount : format.ethUnitAmount; return ( <Container padding="10px 0px" borderTop="1px dashed" borderColor={ColorOption.feintGrey}> <Flex justify="space-between"> - <Text fontWeight={fontWeight} fontColor={ColorOption.grey}> - {rowLabel} + <Text fontWeight={this.props.isLabelBold ? 700 : 400} fontColor={ColorOption.grey}> + {this.props.labelText} </Text> - <Container> - {this._renderUsdSection()} - <Text fontWeight={fontWeight} fontColor={ColorOption.grey}> - {ethFormatter( - ethAmount, - 4, - <Container opacity={0.5}> - <AmountPlaceholder color={ColorOption.lightGrey} isPulsating={isLoading} /> - </Container>, - )} - </Text> - </Container> + <Container>{this._renderValues()}</Container> </Flex> </Container> ); } - private _renderUsdSection(): React.ReactNode { - const usdFormatter = this.props.isEthAmountInBaseUnits - ? format.ethBaseUnitAmountInUsd - : format.ethUnitAmountInUsd; - const shouldHideUsdPriceSection = _.isUndefined(this.props.ethUsdPrice) || _.isUndefined(this.props.ethAmount); - return shouldHideUsdPriceSection ? null : ( + + private _renderValues(): React.ReactNode { + const secondaryValueNode: React.ReactNode = this.props.secondaryValue && ( <Container marginRight="3px" display="inline-block"> - <Text fontColor={ColorOption.lightGrey}> - ({usdFormatter(this.props.ethAmount, this.props.ethUsdPrice)}) - </Text> + <Text fontColor={ColorOption.lightGrey}>({this.props.secondaryValue})</Text> </Container> ); + return ( + <React.Fragment> + {secondaryValueNode} + <Text fontWeight={this.props.isPrimaryValueBold ? 700 : 400}>{this.props.primaryValue}</Text> + </React.Fragment> + ); } } diff --git a/packages/instant/src/components/payment_method.tsx b/packages/instant/src/components/payment_method.tsx index ebcd62f35..ada9f7bab 100644 --- a/packages/instant/src/components/payment_method.tsx +++ b/packages/instant/src/components/payment_method.tsx @@ -8,6 +8,7 @@ import { envUtil } from '../util/env'; import { CoinbaseWalletLogo } from './coinbase_wallet_logo'; import { MetaMaskLogo } from './meta_mask_logo'; import { PaymentMethodDropdown } from './payment_method_dropdown'; +import { SectionHeader } from './section_header'; import { Circle } from './ui/circle'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; @@ -18,26 +19,18 @@ import { WalletPrompt } from './wallet_prompt'; export interface PaymentMethodProps { account: Account; network: Network; - walletName: string; + walletDisplayName: string; onInstallWalletClick: () => void; onUnlockWalletClick: () => void; } -export class PaymentMethod extends React.Component<PaymentMethodProps> { +export class PaymentMethod extends React.PureComponent<PaymentMethodProps> { public render(): React.ReactNode { return ( - <Container padding="20px" width="100%"> + <Container width="100%" height="120px" padding="20px 20px 0px 20px"> <Container marginBottom="12px"> <Flex justify="space-between"> - <Text - letterSpacing="1px" - fontColor={ColorOption.primaryColor} - fontWeight={600} - textTransform="uppercase" - fontSize="14px" - > - {this._renderTitleText()} - </Text> + <SectionHeader>{this._renderTitleText()}</SectionHeader> {this._renderTitleLabel()} </Flex> </Container> @@ -62,11 +55,11 @@ export class PaymentMethod extends React.Component<PaymentMethodProps> { if (account.state === AccountState.Ready || account.state === AccountState.Locked) { const circleColor: ColorOption = account.state === AccountState.Ready ? ColorOption.green : ColorOption.red; return ( - <Flex> + <Flex align="center"> <Circle diameter={8} color={circleColor} /> - <Container marginLeft="3px"> - <Text fontColor={ColorOption.darkGrey} fontSize="12px"> - {this.props.walletName} + <Container marginLeft="5px"> + <Text fontColor={ColorOption.darkGrey} fontSize="12px" lineHeight="30px"> + {this.props.walletDisplayName} </Text> </Container> </Flex> @@ -83,16 +76,19 @@ export class PaymentMethod extends React.Component<PaymentMethodProps> { const colors = { primaryColor, secondaryColor }; switch (account.state) { case AccountState.Loading: - // Just take up the same amount of space as the other states. - return <Container height="52px" />; + return null; case AccountState.Locked: return ( <WalletPrompt onClick={this.props.onUnlockWalletClick} - image={<Icon width={13} icon="lock" color={ColorOption.black} />} + image={ + <Container position="relative" top="2px"> + <Icon width={13} icon="lock" color={ColorOption.black} /> + </Container> + } {...colors} > - Please Unlock {this.props.walletName} + Click to Connect {this.props.walletDisplayName} </WalletPrompt> ); case AccountState.None: diff --git a/packages/instant/src/components/payment_method_dropdown.tsx b/packages/instant/src/components/payment_method_dropdown.tsx index b330dbcd6..e463e3eae 100644 --- a/packages/instant/src/components/payment_method_dropdown.tsx +++ b/packages/instant/src/components/payment_method_dropdown.tsx @@ -1,8 +1,9 @@ import { BigNumber } from '@0x/utils'; -import copy from 'copy-to-clipboard'; +import * as copy from 'copy-to-clipboard'; import * as React from 'react'; import { Network } from '../types'; +import { analytics } from '../util/analytics'; import { envUtil } from '../util/env'; import { etherscanUtil } from '../util/etherscan'; import { format } from '../util/format'; @@ -15,12 +16,19 @@ export interface PaymentMethodDropdownProps { network: Network; } -export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdownProps> { +export class PaymentMethodDropdown extends React.PureComponent<PaymentMethodDropdownProps> { public render(): React.ReactNode { const { accountAddress, accountEthBalanceInWei } = this.props; const value = format.ethAddress(accountAddress); const label = format.ethBaseUnitAmount(accountEthBalanceInWei, 4, '') as string; - return <Dropdown value={value} label={label} items={this._getDropdownItemConfigs()} />; + return ( + <Dropdown + value={value} + label={label} + items={this._getDropdownItemConfigs()} + onOpen={analytics.trackPaymentMethodDropdownOpened} + /> + ); } private readonly _getDropdownItemConfigs = (): DropdownItemConfig[] => { if (envUtil.isMobileOperatingSystem()) { @@ -37,11 +45,15 @@ export class PaymentMethodDropdown extends React.Component<PaymentMethodDropdown return [viewOnEtherscan, copyAddressToClipboard]; }; private readonly _handleEtherscanClick = (): void => { + analytics.trackPaymentMethodOpenedEtherscan(); + const { accountAddress, network } = this.props; const etherscanUrl = etherscanUtil.getEtherScanEthAddressIfExists(accountAddress, network); window.open(etherscanUrl, '_blank'); }; private readonly _handleCopyToClipboardClick = (): void => { + analytics.trackPaymentMethodCopiedAddress(); + const { accountAddress } = this.props; copy(accountAddress); }; diff --git a/packages/instant/src/components/placing_order_button.tsx b/packages/instant/src/components/placing_order_button.tsx index 2516b90b1..528a305dc 100644 --- a/packages/instant/src/components/placing_order_button.tsx +++ b/packages/instant/src/components/placing_order_button.tsx @@ -14,3 +14,5 @@ export const PlacingOrderButton: React.StatelessComponent<{}> = props => ( Placing Order… </Button> ); + +PlacingOrderButton.displayName = 'PlacingOrderButton'; diff --git a/packages/instant/src/components/scaling_amount_input.tsx b/packages/instant/src/components/scaling_amount_input.tsx index 5dc719293..7dc1fdc0c 100644 --- a/packages/instant/src/components/scaling_amount_input.tsx +++ b/packages/instant/src/components/scaling_amount_input.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Maybe } from '../types'; +import { GIT_SHA, MAGIC_TRIGGER_ERROR_INPUT, MAGIC_TRIGGER_ERROR_MESSAGE, NPM_PACKAGE_VERSION } from '../constants'; import { ColorOption } from '../style/theme'; import { maybeBigNumberUtil } from '../util/maybe_big_number'; import { util } from '../util/util'; @@ -18,17 +19,19 @@ export interface ScalingAmountInputProps { value?: BigNumber; onAmountChange: (value?: BigNumber) => void; onFontSizeChange: (fontSizePx: number) => void; + hasAutofocus: boolean; } interface ScalingAmountInputState { stringValue: string; } const { stringToMaybeBigNumber, areMaybeBigNumbersEqual } = maybeBigNumberUtil; -export class ScalingAmountInput extends React.Component<ScalingAmountInputProps, ScalingAmountInputState> { +export class ScalingAmountInput extends React.PureComponent<ScalingAmountInputProps, ScalingAmountInputState> { public static defaultProps = { onAmountChange: util.boundNoop, onFontSizeChange: util.boundNoop, isDisabled: false, + hasAutofocus: false, }; public constructor(props: ScalingAmountInputProps) { super(props); @@ -55,6 +58,7 @@ export class ScalingAmountInput extends React.Component<ScalingAmountInputProps, const { textLengthThreshold, fontColor, maxFontSizePx, onFontSizeChange } = this.props; return ( <ScalingInput + type="number" maxFontSizePx={maxFontSizePx} textLengthThreshold={textLengthThreshold} onFontSizeChange={onFontSizeChange} @@ -64,10 +68,15 @@ export class ScalingAmountInput extends React.Component<ScalingAmountInputProps, placeholder="0.00" emptyInputWidthCh={3.5} isDisabled={this.props.isDisabled} + hasAutofocus={this.props.hasAutofocus} /> ); } private readonly _handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + if (event.target.value === MAGIC_TRIGGER_ERROR_INPUT) { + throw new Error(`${MAGIC_TRIGGER_ERROR_MESSAGE} git: ${GIT_SHA}, npm: ${NPM_PACKAGE_VERSION}`); + } + const sanitizedValue = event.target.value.replace(/[^0-9.]/g, ''); // only allow numbers and "." this.setState({ stringValue: sanitizedValue, diff --git a/packages/instant/src/components/scaling_input.tsx b/packages/instant/src/components/scaling_input.tsx index e1599a316..c31de1fb5 100644 --- a/packages/instant/src/components/scaling_input.tsx +++ b/packages/instant/src/components/scaling_input.tsx @@ -1,3 +1,4 @@ +import { ObjectMap } from '@0x/types'; import * as _ from 'lodash'; import * as React from 'react'; @@ -13,10 +14,15 @@ export enum ScalingInputPhase { export interface ScalingSettings { percentageToReduceFontSizePerCharacter: number; - constantPxToIncreaseWidthPerCharacter: number; + // 1ch = the width of the 0 chararacter. + // Allow to customize 'char' length for different characters. + characterWidthOverrides: ObjectMap<number>; + // How much room to leave to the right of the scaling input. + additionalInputSpaceInCh: number; } export interface ScalingInputProps { + type?: string; textLengthThreshold: number; maxFontSizePx: number; value: string; @@ -28,10 +34,7 @@ export interface ScalingInputProps { maxLength?: number; scalingSettings: ScalingSettings; isDisabled: boolean; -} - -export interface ScalingInputState { - inputWidthPxAtPhaseChange?: number; + hasAutofocus: boolean; } export interface ScalingInputSnapshot { @@ -40,20 +43,22 @@ export interface ScalingInputSnapshot { // These are magic numbers that were determined experimentally. const defaultScalingSettings: ScalingSettings = { - percentageToReduceFontSizePerCharacter: 0.125, - constantPxToIncreaseWidthPerCharacter: 4, + percentageToReduceFontSizePerCharacter: 0.1, + characterWidthOverrides: { + '1': 0.7, + '.': 0.4, + }, + additionalInputSpaceInCh: 0.4, }; -export class ScalingInput extends React.Component<ScalingInputProps, ScalingInputState> { +export class ScalingInput extends React.PureComponent<ScalingInputProps> { public static defaultProps = { onChange: util.boundNoop, onFontSizeChange: util.boundNoop, - maxLength: 7, + maxLength: 9, scalingSettings: defaultScalingSettings, isDisabled: false, - }; - public state: ScalingInputState = { - inputWidthPxAtPhaseChange: undefined, + hasAutofocus: false, }; private readonly _inputRef = React.createRef<HTMLInputElement>(); public static getPhase(textLengthThreshold: number, value: string): ScalingInputPhase { @@ -91,30 +96,15 @@ export class ScalingInput extends React.Component<ScalingInputProps, ScalingInpu scalingSettings.percentageToReduceFontSizePerCharacter, ); } - public getSnapshotBeforeUpdate(): ScalingInputSnapshot { - return { - inputWidthPx: this._getInputWidthInPx(), - }; + public componentDidMount(): void { + // Trigger an initial notification of the calculated fontSize. + const currentPhase = ScalingInput.getPhaseFromProps(this.props); + const currentFontSize = ScalingInput.calculateFontSizeFromProps(this.props, currentPhase); + this.props.onFontSizeChange(currentFontSize); } - public componentDidUpdate( - prevProps: ScalingInputProps, - prevState: ScalingInputState, - snapshot: ScalingInputSnapshot, - ): void { + public componentDidUpdate(prevProps: ScalingInputProps): 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. @@ -123,49 +113,53 @@ export class ScalingInput extends React.Component<ScalingInputProps, ScalingInpu } } public render(): React.ReactNode { - const { isDisabled, fontColor, onChange, placeholder, value, maxLength } = this.props; + const { type, hasAutofocus, isDisabled, fontColor, placeholder, value, maxLength } = this.props; const phase = ScalingInput.getPhaseFromProps(this.props); return ( <Input + type={type} ref={this._inputRef as any} fontColor={fontColor} - onChange={onChange} + onChange={this._handleChange} value={value} placeholder={placeholder} fontSize={`${this._calculateFontSize(phase)}px`} width={this._calculateWidth(phase)} maxLength={maxLength} disabled={isDisabled} + autoFocus={hasAutofocus} /> ); } private readonly _calculateWidth = (phase: ScalingInputPhase): string => { - const { value, textLengthThreshold, scalingSettings } = this.props; + const { value, 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`; + const lengthInCh = _.reduce( + value.split(''), + (sum, char) => { + const widthOverride = scalingSettings.characterWidthOverrides[char]; + if (!_.isUndefined(widthOverride)) { + // tslint is confused + // tslint:disable-next-line:restrict-plus-operands + return sum + widthOverride; } - return `${textLengthThreshold}ch`; - } + return sum + 1; + }, + scalingSettings.additionalInputSpaceInCh, + ); + return `${lengthInCh}ch`; }; private readonly _calculateFontSize = (phase: ScalingInputPhase): number => { return ScalingInput.calculateFontSizeFromProps(this.props, phase); }; - private readonly _getInputWidthInPx = (): number => { - const ref = this._inputRef.current; - if (!ref) { - return 0; + private readonly _handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { + const value = event.target.value; + const { maxLength } = this.props; + if (!_.isUndefined(value) && !_.isUndefined(maxLength) && value.length > maxLength) { + return; } - return ref.getBoundingClientRect().width; + this.props.onChange(event); }; } diff --git a/packages/instant/src/components/search_input.tsx b/packages/instant/src/components/search_input.tsx index 3a693b9f8..71bc18915 100644 --- a/packages/instant/src/components/search_input.tsx +++ b/packages/instant/src/components/search_input.tsx @@ -13,10 +13,10 @@ export interface SearchInputProps extends InputProps { } export const SearchInput: React.StatelessComponent<SearchInputProps> = props => ( - <Container backgroundColor={props.backgroundColor} borderRadius="3px" padding=".5em .3em"> - <Flex justify="flex-start" align="flex-end"> - <Icon width={14} height={14} icon="search" color={ColorOption.lightGrey} padding="0px 12px" /> - <Input {...props} fontSize="14px" fontColor={props.fontColor} /> + <Container backgroundColor={props.backgroundColor} borderRadius="3px" padding=".5em .5em"> + <Flex justify="flex-start" align="center"> + <Icon width={14} height={14} icon="search" color={ColorOption.lightGrey} padding="2px 12px" /> + <Input {...props} type="search" fontSize="16px" fontColor={props.fontColor} /> </Flex> </Container> ); diff --git a/packages/instant/src/components/secondary_button.tsx b/packages/instant/src/components/secondary_button.tsx index 705390e28..0714ce287 100644 --- a/packages/instant/src/components/secondary_button.tsx +++ b/packages/instant/src/components/secondary_button.tsx @@ -24,3 +24,4 @@ export const SecondaryButton: React.StatelessComponent<SecondaryButtonProps> = p SecondaryButton.defaultProps = { width: '100%', }; +SecondaryButton.displayName = 'SecondaryButton'; diff --git a/packages/instant/src/components/section_header.tsx b/packages/instant/src/components/section_header.tsx new file mode 100644 index 000000000..2185b67ba --- /dev/null +++ b/packages/instant/src/components/section_header.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Text } from './ui/text'; + +export interface SectionHeaderProps {} +export const SectionHeader: React.StatelessComponent<SectionHeaderProps> = props => { + return ( + <Text + letterSpacing="1px" + fontColor={ColorOption.primaryColor} + fontWeight={600} + textTransform="uppercase" + fontSize="12px" + > + {props.children} + </Text> + ); +}; +SectionHeader.displayName = 'SectionHeader'; diff --git a/packages/instant/src/components/sliding_error.tsx b/packages/instant/src/components/sliding_error.tsx index b59e2a905..c7c6732cf 100644 --- a/packages/instant/src/components/sliding_error.tsx +++ b/packages/instant/src/components/sliding_error.tsx @@ -38,6 +38,8 @@ export const Error: React.StatelessComponent<ErrorProps> = props => ( </Container> ); +Error.displayName = 'Error'; + export interface SlidingErrorProps extends ErrorProps { animationState: SlideAnimationState; } @@ -94,3 +96,5 @@ export const SlidingError: React.StatelessComponent<SlidingErrorProps> = props = </SlideAnimation> ); }; + +SlidingError.displayName = 'SlidingError'; diff --git a/packages/instant/src/components/sliding_panel.tsx b/packages/instant/src/components/sliding_panel.tsx index 7f9037049..9b09a0d80 100644 --- a/packages/instant/src/components/sliding_panel.tsx +++ b/packages/instant/src/components/sliding_panel.tsx @@ -26,42 +26,48 @@ export const Panel: React.StatelessComponent<PanelProps> = ({ children, onClose </Container> ); +Panel.displayName = 'Panel'; + export interface SlidingPanelProps extends PanelProps { animationState: SlideAnimationState; + onAnimationEnd?: () => void; } -export const SlidingPanel: React.StatelessComponent<SlidingPanelProps> = props => { - if (props.animationState === 'none') { - return null; +export class SlidingPanel extends React.PureComponent<SlidingPanelProps> { + public render(): React.ReactNode { + if (this.props.animationState === 'none') { + return null; + } + const { animationState, onAnimationEnd, ...rest } = this.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%" + onAnimationEnd={onAnimationEnd} + > + <Panel {...rest} /> + </SlideAnimation> + ); } - const { animationState, ...rest } = props; - const slideAmount = '100%'; - const slideUpSettings: PositionAnimationSettings = { - duration: '0.3s', - timingFunction: 'ease-in-out', - top: { - from: slideAmount, - to: '0px', - }, - position: 'absolute', - }; - const slideDownSettings: PositionAnimationSettings = { - duration: '0.3s', - timingFunction: 'ease-out', - top: { - from: '0px', - to: slideAmount, - }, - position: 'absolute', - }; - return ( - <SlideAnimation - slideInSettings={slideUpSettings} - slideOutSettings={slideDownSettings} - animationState={animationState} - height="100%" - > - <Panel {...rest} /> - </SlideAnimation> - ); -}; +} diff --git a/packages/instant/src/components/standard_panel_content.tsx b/packages/instant/src/components/standard_panel_content.tsx index 582b3318e..f2987df82 100644 --- a/packages/instant/src/components/standard_panel_content.tsx +++ b/packages/instant/src/components/standard_panel_content.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ColorOption } from '../style/theme'; +import { util } from '../util/util'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; @@ -9,6 +10,7 @@ import { Text } from './ui/text'; export interface MoreInfoSettings { text: string; href: string; + onClick?: () => void; } export interface StandardPanelContentProps { @@ -21,6 +23,15 @@ export interface StandardPanelContentProps { const SPACING_BETWEEN_PX = '20px'; +const onMoreInfoClick = (href: string, onClick?: () => void) => { + return () => { + if (onClick) { + onClick(); + } + util.createOpenUrlInNewWindow(href)(); + }; +}; + export const StandardPanelContent: React.StatelessComponent<StandardPanelContentProps> = ({ image, title, @@ -50,7 +61,7 @@ export const StandardPanelContent: React.StatelessComponent<StandardPanelContent fontSize="13px" textDecorationLine="underline" fontColor={ColorOption.lightGrey} - href={moreInfoSettings.href} + onClick={onMoreInfoClick(moreInfoSettings.href, moreInfoSettings.onClick)} > {moreInfoSettings.text} </Text> @@ -60,3 +71,5 @@ export const StandardPanelContent: React.StatelessComponent<StandardPanelContent <Container>{action}</Container> </Container> ); + +StandardPanelContent.displayName = 'StandardPanelContent'; diff --git a/packages/instant/src/components/standard_sliding_panel.tsx b/packages/instant/src/components/standard_sliding_panel.tsx index f587ff79a..bcc9d3dce 100644 --- a/packages/instant/src/components/standard_sliding_panel.tsx +++ b/packages/instant/src/components/standard_sliding_panel.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { SlideAnimationState, StandardSlidingPanelContent, StandardSlidingPanelSettings } from '../types'; +import { StandardSlidingPanelContent, StandardSlidingPanelSettings } from '../types'; import { InstallWalletPanelContent } from './install_wallet_panel_content'; import { SlidingPanel } from './sliding_panel'; @@ -9,7 +9,7 @@ export interface StandardSlidingPanelProps extends StandardSlidingPanelSettings onClose: () => void; } -export class StandardSlidingPanel extends React.Component<StandardSlidingPanelProps> { +export class StandardSlidingPanel extends React.PureComponent<StandardSlidingPanelProps> { public render(): React.ReactNode { const { animationState, content, onClose } = this.props; return ( diff --git a/packages/instant/src/components/time_counter.tsx b/packages/instant/src/components/time_counter.tsx index f9b68163c..93dc497d5 100644 --- a/packages/instant/src/components/time_counter.tsx +++ b/packages/instant/src/components/time_counter.tsx @@ -16,7 +16,7 @@ interface TimeCounterState { elapsedSeconds: number; } -export class TimeCounter extends React.Component<TimeCounterProps, TimeCounterState> { +export class TimeCounter extends React.PureComponent<TimeCounterProps, TimeCounterState> { public state = { elapsedSeconds: 0, }; diff --git a/packages/instant/src/components/timed_progress_bar.tsx b/packages/instant/src/components/timed_progress_bar.tsx index 8465b9cd0..b1644b871 100644 --- a/packages/instant/src/components/timed_progress_bar.tsx +++ b/packages/instant/src/components/timed_progress_bar.tsx @@ -1,8 +1,9 @@ import * as _ from 'lodash'; +import { transparentize } from 'polished'; import * as React from 'react'; import { PROGRESS_FINISH_ANIMATION_TIME_MS, PROGRESS_STALL_AT_WIDTH } from '../constants'; -import { ColorOption, css, keyframes, styled } from '../style/theme'; +import { ColorOption, css, keyframes, styled, ThemeConsumer } from '../style/theme'; import { Container } from './ui/container'; @@ -16,7 +17,7 @@ export interface TimedProgressBarProps { * 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, {}> { +export class TimedProgressBar extends React.PureComponent<TimedProgressBarProps, {}> { private readonly _barRef = React.createRef<HTMLDivElement>(); public render(): React.ReactNode { @@ -93,8 +94,16 @@ export interface ProgressBarProps extends ProgressProps {} export const ProgressBar: React.ComponentType<ProgressBarProps & React.ClassAttributes<{}>> = React.forwardRef( (props, ref) => ( - <Container width="100%" backgroundColor={ColorOption.lightGrey} borderRadius="6px"> - <Progress {...props} ref={ref as any} /> - </Container> + <ThemeConsumer> + {theme => ( + <Container + width="100%" + borderRadius="6px" + rawBackgroundColor={transparentize(0.5, theme[ColorOption.primaryColor])} + > + <Progress {...props} ref={ref as any} /> + </Container> + )} + </ThemeConsumer> ), ); diff --git a/packages/instant/src/components/ui/container.tsx b/packages/instant/src/components/ui/container.tsx index 4dafe1386..58d7d5871 100644 --- a/packages/instant/src/components/ui/container.tsx +++ b/packages/instant/src/components/ui/container.tsx @@ -27,17 +27,30 @@ export interface ContainerProps { borderBottom?: string; className?: string; backgroundColor?: ColorOption; + rawBackgroundColor?: string; hasBoxShadow?: boolean; + isHidden?: boolean; zIndex?: number; whiteSpace?: string; opacity?: number; cursor?: string; overflow?: string; darkenOnHover?: boolean; + rawHoverColor?: string; boxShadowOnHover?: boolean; flexGrow?: string | number; } +const getBackgroundColor = (theme: any, backgroundColor?: ColorOption, rawBackgroundColor?: string): string => { + if (backgroundColor) { + return theme[backgroundColor] as string; + } + if (rawBackgroundColor) { + return rawBackgroundColor; + } + return 'none'; +}; + export const Container = styled.div < ContainerProps > @@ -65,14 +78,17 @@ export const Container = ${props => cssRuleIfExists(props, 'opacity')} ${props => cssRuleIfExists(props, 'cursor')} ${props => cssRuleIfExists(props, 'overflow')} + ${props => (props.overflow === 'scroll' ? `-webkit-overflow-scrolling: touch` : '')}; ${props => (props.hasBoxShadow ? `box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1)` : '')}; ${props => props.display && stylesForMedia<string>('display', props.display)} ${props => props.width && stylesForMedia<string>('width', props.width)} ${props => props.height && stylesForMedia<string>('height', props.height)} ${props => props.borderRadius && stylesForMedia<string>('border-radius', props.borderRadius)} - background-color: ${props => (props.backgroundColor ? props.theme[props.backgroundColor] : 'none')}; + ${props => (props.isHidden ? 'visibility: hidden;' : '')} + background-color: ${props => getBackgroundColor(props.theme, props.backgroundColor, props.rawBackgroundColor)}; border-color: ${props => (props.borderColor ? props.theme[props.borderColor] : 'none')}; &:hover { + ${props => (props.rawHoverColor ? `background-color: ${props.rawHoverColor}` : '')} ${props => props.darkenOnHover ? `background-color: ${ diff --git a/packages/instant/src/components/ui/dropdown.tsx b/packages/instant/src/components/ui/dropdown.tsx index 3a23f456d..8788d3d59 100644 --- a/packages/instant/src/components/ui/dropdown.tsx +++ b/packages/instant/src/components/ui/dropdown.tsx @@ -1,7 +1,8 @@ import * as _ from 'lodash'; +import { transparentize } from 'polished'; import * as React from 'react'; -import { ColorOption, completelyTransparent } from '../../style/theme'; +import { ColorOption, completelyTransparent, ThemeConsumer } from '../../style/theme'; import { zIndex } from '../../style/z_index'; import { Container } from './container'; @@ -19,13 +20,14 @@ export interface DropdownProps { value: string; label?: string; items: DropdownItemConfig[]; + onOpen?: () => void; } export interface DropdownState { isOpen: boolean; } -export class Dropdown extends React.Component<DropdownProps, DropdownState> { +export class Dropdown extends React.PureComponent<DropdownProps, DropdownState> { public static defaultProps = { items: [], }; @@ -97,9 +99,14 @@ export class Dropdown extends React.Component<DropdownProps, DropdownState> { if (_.isEmpty(this.props.items)) { return; } + const isOpen = !this.state.isOpen; this.setState({ - isOpen: !this.state.isOpen, + isOpen, }); + + if (isOpen && this.props.onOpen) { + this.props.onOpen(); + } }; private readonly _closeDropdown = (): void => { this.setState({ @@ -115,20 +122,26 @@ export interface DropdownItemProps extends DropdownItemConfig { } 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> + <ThemeConsumer> + {theme => ( + <Container + onClick={onClick} + cursor="pointer" + rawHoverColor={transparentize(0.9, theme[ColorOption.primaryColor])} + 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> + )} + </ThemeConsumer> ); + +DropdownItem.displayName = 'DropdownItem'; diff --git a/packages/instant/src/components/ui/input.tsx b/packages/instant/src/components/ui/input.tsx index 1ea5d8fe1..53c43ea0b 100644 --- a/packages/instant/src/components/ui/input.tsx +++ b/packages/instant/src/components/ui/input.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ColorOption, styled } from '../../style/theme'; -export interface InputProps { +export interface InputProps extends React.HTMLAttributes<HTMLInputElement> { tabIndex?: number; className?: string; value?: string; @@ -10,6 +10,7 @@ export interface InputProps { fontSize?: string; fontColor?: ColorOption; placeholder?: string; + type?: string; onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void; } @@ -28,9 +29,13 @@ export const Input = outline: none; border: none; &::placeholder { - color: ${props => props.theme[props.fontColor || 'white']}; - opacity: 0.5; + color: ${props => props.theme[props.fontColor || 'white']} !important; + opacity: 0.5 !important; } + &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } } `; diff --git a/packages/instant/src/components/ui/overlay.tsx b/packages/instant/src/components/ui/overlay.tsx index f67d6fb2f..0b5eaf299 100644 --- a/packages/instant/src/components/ui/overlay.tsx +++ b/packages/instant/src/components/ui/overlay.tsx @@ -33,7 +33,7 @@ export const Overlay = Overlay.defaultProps = { zIndex: zIndex.overlayDefault, - backgroundColor: generateOverlayBlack(0.6), + backgroundColor: generateOverlayBlack(0.7), }; Overlay.displayName = 'Overlay'; diff --git a/packages/instant/src/components/ui/text.tsx b/packages/instant/src/components/ui/text.tsx index fd14cc4d1..282477758 100644 --- a/packages/instant/src/components/ui/text.tsx +++ b/packages/instant/src/components/ui/text.tsx @@ -1,4 +1,3 @@ -import { darken } from 'polished'; import * as React from 'react'; import { ColorOption, styled } from '../../style/theme'; @@ -11,6 +10,7 @@ export interface TextProps { fontSize?: string; opacity?: number; letterSpacing?: string; + textAlign?: string; textTransform?: string; lineHeight?: string; className?: string; @@ -22,6 +22,7 @@ export interface TextProps { noWrap?: boolean; display?: string; href?: string; + width?: string; } export const Text: React.StatelessComponent<TextProps> = ({ href, onClick, ...rest }) => { @@ -29,7 +30,7 @@ export const Text: React.StatelessComponent<TextProps> = ({ href, onClick, ...re return <StyledText {...rest} onClick={computedOnClick} />; }; -const darkenOnHoverAmount = 0.3; +const opacityOnHoverAmount = 0.5; export const StyledText = styled.div < TextProps > @@ -51,9 +52,10 @@ export const StyledText = ${props => (props.display ? `display: ${props.display}` : '')}; ${props => (props.letterSpacing ? `letter-spacing: ${props.letterSpacing}` : '')}; ${props => (props.textTransform ? `text-transform: ${props.textTransform}` : '')}; + ${props => (props.textAlign ? `text-align: ${props.textAlign}` : '')}; + ${props => (props.width ? `width: ${props.width}` : '')}; &:hover { - ${props => - props.onClick ? `color: ${darken(darkenOnHoverAmount, props.theme[props.fontColor || 'white'])}` : ''}; + ${props => (props.onClick ? `opacity: ${opacityOnHoverAmount};` : '')}; } } `; diff --git a/packages/instant/src/components/wallet_prompt.tsx b/packages/instant/src/components/wallet_prompt.tsx index a0b3ae457..10433767f 100644 --- a/packages/instant/src/components/wallet_prompt.tsx +++ b/packages/instant/src/components/wallet_prompt.tsx @@ -21,7 +21,7 @@ export const WalletPrompt: React.StatelessComponent<WalletPromptProps> = ({ primaryColor, }) => ( <Container - padding="14.5px" + padding="10px" border={`1px solid ${primaryColor}`} backgroundColor={secondaryColor} width="100%" @@ -33,7 +33,7 @@ export const WalletPrompt: React.StatelessComponent<WalletPromptProps> = ({ <Flex> {image} <Container marginLeft="10px"> - <Text fontSize="16px" fontColor={primaryColor}> + <Text fontSize="16px" fontColor={primaryColor} fontWeight="500"> {children} </Text> </Container> @@ -45,3 +45,5 @@ WalletPrompt.defaultProps = { primaryColor: ColorOption.darkOrange, secondaryColor: ColorOption.lightOrange, }; + +WalletPrompt.displayName = 'WalletPrompt'; diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx index 2267b4dbf..e9cb48e61 100644 --- a/packages/instant/src/components/zero_ex_instant.tsx +++ b/packages/instant/src/components/zero_ex_instant.tsx @@ -17,3 +17,5 @@ export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = props </div> ); }; + +ZeroExInstant.displayName = 'ZeroExInstant'; diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index 47c938472..e8c64d5d1 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -11,21 +11,23 @@ import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_ import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading'; import { ColorOption } from '../style/theme'; import { zIndex } from '../style/z_index'; -import { OrderProcessState, SlideAnimationState } from '../types'; +import { SlideAnimationState } from '../types'; +import { analytics, TokenSelectorClosedVia } from '../util/analytics'; import { CSSReset } from './css_reset'; import { SlidingPanel } from './sliding_panel'; import { Container } from './ui/container'; import { Flex } from './ui/flex'; -export interface ZeroExInstantContainerProps { - orderProcessState: OrderProcessState; -} +export interface ZeroExInstantContainerProps {} export interface ZeroExInstantContainerState { tokenSelectionPanelAnimationState: SlideAnimationState; } -export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantContainerState> { +export class ZeroExInstantContainer extends React.PureComponent< + ZeroExInstantContainerProps, + ZeroExInstantContainerState +> { public state = { tokenSelectionPanelAnimationState: 'none' as SlideAnimationState, }; @@ -60,9 +62,10 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon </Flex> <SlidingPanel animationState={this.state.tokenSelectionPanelAnimationState} - onClose={this._handlePanelClose} + onClose={this._handlePanelCloseClickedX} + onAnimationEnd={this._handleSlidingPanelAnimationEnd} > - <AvailableERC20TokenSelector onTokenSelect={this._handlePanelClose} /> + <AvailableERC20TokenSelector onTokenSelect={this._handlePanelCloseAfterChose} /> </SlidingPanel> <CurrentStandardSlidingPanel /> </Container> @@ -71,7 +74,7 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon marginTop="10px" marginLeft="auto" marginRight="auto" - width="140px" + width="108px" > <a href={ZERO_EX_SITE_URL} target="_blank"> <PoweredByLogo /> @@ -82,13 +85,28 @@ export class ZeroExInstantContainer extends React.Component<{}, ZeroExInstantCon ); } private readonly _handleSymbolClick = (): void => { + analytics.trackTokenSelectorOpened(); this.setState({ tokenSelectionPanelAnimationState: 'slidIn', }); }; - private readonly _handlePanelClose = (): void => { + private readonly _handlePanelCloseClickedX = (): void => { + this._handlePanelClose(TokenSelectorClosedVia.ClickedX); + }; + private readonly _handlePanelCloseAfterChose = (): void => { + this._handlePanelClose(TokenSelectorClosedVia.TokenChose); + }; + private readonly _handlePanelClose = (closedVia: TokenSelectorClosedVia): void => { + analytics.trackTokenSelectorClosed(closedVia); this.setState({ tokenSelectionPanelAnimationState: 'slidOut', }); }; + private readonly _handleSlidingPanelAnimationEnd = (): void => { + if (this.state.tokenSelectionPanelAnimationState === 'slidOut') { + // When the slidOut animation completes, don't keep the panel mounted. + // Performance optimization + this.setState({ tokenSelectionPanelAnimationState: 'none' }); + } + }; } diff --git a/packages/instant/src/components/zero_ex_instant_overlay.tsx b/packages/instant/src/components/zero_ex_instant_overlay.tsx index 2856ea3e3..38a716091 100644 --- a/packages/instant/src/components/zero_ex_instant_overlay.tsx +++ b/packages/instant/src/components/zero_ex_instant_overlay.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ZeroExInstantContainer } from '../components/zero_ex_instant_container'; +import { MAIN_CONTAINER_DIV_CLASS, OVERLAY_CLOSE_BUTTON_DIV_CLASS, OVERLAY_DIV_CLASS } from '../constants'; import { ColorOption } from '../style/theme'; import { Container } from './ui/container'; @@ -18,9 +19,15 @@ export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlay const { onClose, zIndex, ...rest } = props; return ( <ZeroExInstantProvider {...rest}> - <Overlay zIndex={zIndex}> + <Overlay zIndex={zIndex} className={OVERLAY_DIV_CLASS}> <Flex height="100vh"> - <Container position="absolute" top="0px" right="0px" display={{ default: 'initial', sm: 'none' }}> + <Container + className={OVERLAY_CLOSE_BUTTON_DIV_CLASS} + position="absolute" + top="0px" + right="0px" + display={{ default: 'initial', sm: 'none' }} + > <Icon height={18} width={18} @@ -30,7 +37,11 @@ export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlay padding="2em 2em" /> </Container> - <Container width={{ default: 'auto', sm: '100%' }} height={{ default: 'auto', sm: '100%' }}> + <Container + width={{ default: 'auto', sm: '100%' }} + height={{ default: 'auto', sm: '100%' }} + className={MAIN_CONTAINER_DIV_CLASS} + > <ZeroExInstantContainer /> </Container> </Flex> @@ -38,3 +49,5 @@ export const ZeroExInstantOverlay: React.StatelessComponent<ZeroExInstantOverlay </ZeroExInstantProvider> ); }; + +ZeroExInstantOverlay.displayName = 'ZeroExInstantOverlay'; diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index 9435d8c7c..ec8e82ee3 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -1,6 +1,4 @@ -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'; @@ -11,36 +9,19 @@ import { asyncData } from '../redux/async_data'; import { DEFAULT_STATE, DefaultState, State } from '../redux/reducer'; import { store, Store } from '../redux/store'; import { fonts } from '../style/fonts'; -import { AccountState, AffiliateInfo, AssetMetaData, Network, OrderSource } from '../types'; +import { AccountState, Network, QuoteFetchOrigin, ZeroExInstantBaseConfig } from '../types'; import { analytics, disableAnalytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; +import { setupRollbar } from '../util/error_reporter'; import { gasPriceEstimator } from '../util/gas_price_estimator'; import { Heartbeater } from '../util/heartbeater'; import { generateAccountHeartbeater, generateBuyQuoteHeartbeater } from '../util/heartbeater_factory'; import { providerStateFactory } from '../util/provider_state_factory'; -fonts.include(); +export type ZeroExInstantProviderProps = ZeroExInstantBaseConfig; -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; - shouldDisableAnalyticsTracking: boolean; -} - -export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { +export class ZeroExInstantProvider extends React.PureComponent<ZeroExInstantProviderProps> { private readonly _store: Store; private _accountUpdateHeartbeat?: Heartbeater; private _buyQuoteHeartbeat?: Heartbeater; @@ -57,10 +38,12 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider props.orderSource, networkId, props.provider, + props.walletDisplayName, ); // merge the additional additionalAssetMetaDataMap with our default map const completeAssetMetaDataMap = { - ...props.additionalAssetMetaDataMap, + // Make sure the passed in assetDatas are lower case + ..._.mapKeys(props.additionalAssetMetaDataMap || {}, (value, key) => key.toLowerCase()), ...defaultState.assetMetaDataMap, }; // construct the final state @@ -68,6 +51,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider ...defaultState, providerState, network: networkId, + walletDisplayName: props.walletDisplayName, selectedAsset: _.isUndefined(props.defaultSelectedAssetData) ? undefined : assetUtils.createAssetFromAssetDataOrThrow( @@ -88,6 +72,8 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider } constructor(props: ZeroExInstantProviderProps) { super(props); + setupRollbar(); + fonts.include(); const initialAppState = ZeroExInstantProvider._mergeDefaultStateWithProps(this.props); this._store = store.create(initialAppState); } @@ -116,7 +102,9 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider this._buyQuoteHeartbeat.start(BUY_QUOTE_UPDATE_INTERVAL_TIME_MS); // Trigger first buyquote fetch // tslint:disable-next-line:no-floating-promises - asyncData.fetchCurrentBuyQuoteAndDispatchToStore(state, dispatch, { updateSilently: false }); + asyncData.fetchCurrentBuyQuoteAndDispatchToStore(state, dispatch, QuoteFetchOrigin.Manual, { + updateSilently: false, + }); // warm up the gas price estimator cache just in case we can't // grab the gas price estimate when submitting the transaction // tslint:disable-next-line:no-floating-promises @@ -126,14 +114,17 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider // Analytics disableAnalytics(this.props.shouldDisableAnalyticsTracking || false); - analytics.addEventProperties({ - embeddedHost: window.location.host, - embeddedUrl: window.location.href, - networkId: state.network, - providerName: state.providerState.name, - gitSha: process.env.GIT_SHA, - npmVersion: process.env.NPM_PACKAGE_VERSION, - }); + analytics.addEventProperties( + analytics.generateEventProperties( + state.network, + this.props.orderSource, + state.providerState, + window, + state.selectedAsset, + this.props.affiliateInfo, + state.baseCurrency, + ), + ); analytics.trackInstantOpened(); } public componentWillUnmount(): void { |