diff options
-rw-r--r-- | packages/instant/src/components/instant_heading.tsx | 15 | ||||
-rw-r--r-- | packages/instant/src/components/order_details.tsx | 265 | ||||
-rw-r--r-- | packages/instant/src/components/payment_method.tsx | 11 | ||||
-rw-r--r-- | packages/instant/src/components/section_header.tsx | 20 | ||||
-rw-r--r-- | packages/instant/src/components/zero_ex_instant_provider.tsx | 1 | ||||
-rw-r--r-- | packages/instant/src/constants.ts | 1 | ||||
-rw-r--r-- | packages/instant/src/containers/latest_buy_quote_order_details.ts | 33 | ||||
-rw-r--r-- | packages/instant/src/redux/actions.ts | 4 | ||||
-rw-r--r-- | packages/instant/src/redux/analytics_middleware.ts | 3 | ||||
-rw-r--r-- | packages/instant/src/redux/async_data.ts | 4 | ||||
-rw-r--r-- | packages/instant/src/redux/reducer.ts | 8 | ||||
-rw-r--r-- | packages/instant/src/types.ts | 5 | ||||
-rw-r--r-- | packages/instant/src/util/analytics.ts | 9 | ||||
-rw-r--r-- | packages/instant/src/util/asset.ts | 3 | ||||
-rw-r--r-- | packages/instant/src/util/format.ts | 30 | ||||
-rw-r--r-- | packages/instant/test/util/format.test.ts | 18 |
16 files changed, 314 insertions, 116 deletions
diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index 816cc5c33..5b1f9592d 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -113,20 +113,23 @@ 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" + fontSize={fontSize} textAlign="right" width="100%" fontColor={ColorOption.white} fontWeight={500} noWrap={true} > - {format.ethBaseUnitAmount( - this.props.totalEthBaseUnitAmount, - 4, - <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, - )} + {ethAmount} </Text> ); }; diff --git a/packages/instant/src/components/order_details.tsx b/packages/instant/src/components/order_details.tsx index a8e0e2513..9c10ef9e6 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> { 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 width="100%" flexGrow={1} padding="20px 20px 0px 20px"> - <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 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.Component<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 7c93f1d1c..abadf4bd6 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'; @@ -29,15 +30,7 @@ export class PaymentMethod extends React.Component<PaymentMethodProps> { <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> diff --git a/packages/instant/src/components/section_header.tsx b/packages/instant/src/components/section_header.tsx new file mode 100644 index 000000000..d0974ebdc --- /dev/null +++ b/packages/instant/src/components/section_header.tsx @@ -0,0 +1,20 @@ +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> + ); +}; diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index 204115fa9..2de327cd7 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -122,6 +122,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider window, state.selectedAsset, this.props.affiliateInfo, + state.baseCurrency, ), ); analytics.trackInstantOpened(); diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts index f83eb4ac7..975dfcbea 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -17,6 +17,7 @@ export const ONE_MINUTE_MS = ONE_SECOND_MS * 60; export const GIT_SHA = process.env.GIT_SHA; export const NODE_ENV = process.env.NODE_ENV; export const NPM_PACKAGE_VERSION = process.env.NPM_PACKAGE_VERSION; +export const DEFAULT_UNKOWN_ASSET_NAME = '???'; export const ACCOUNT_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 5; export const BUY_QUOTE_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 15; export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6); diff --git a/packages/instant/src/containers/latest_buy_quote_order_details.ts b/packages/instant/src/containers/latest_buy_quote_order_details.ts index 5dfe535e7..148735c47 100644 --- a/packages/instant/src/containers/latest_buy_quote_order_details.ts +++ b/packages/instant/src/containers/latest_buy_quote_order_details.ts @@ -1,32 +1,41 @@ -import { BuyQuoteInfo } from '@0x/asset-buyer'; -import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; import * as React from 'react'; import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; import { oc } from 'ts-optchain'; +import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; -import { OrderDetails } from '../components/order_details'; -import { AsyncProcessState } from '../types'; +import { OrderDetails, OrderDetailsProps } from '../components/order_details'; +import { AsyncProcessState, BaseCurrency, Omit } from '../types'; +import { assetUtils } from '../util/asset'; -export interface LatestBuyQuoteOrderDetailsProps {} - -interface ConnectedState { - buyQuoteInfo?: BuyQuoteInfo; - selectedAssetUnitAmount?: BigNumber; - ethUsdPrice?: BigNumber; - isLoading: boolean; -} +type DispatchProperties = 'onBaseCurrencySwitchEth' | 'onBaseCurrencySwitchUsd'; +interface ConnectedState extends Omit<OrderDetailsProps, DispatchProperties> {} const mapStateToProps = (state: State, _ownProps: LatestBuyQuoteOrderDetailsProps): ConnectedState => ({ // use the worst case quote info buyQuoteInfo: oc(state).latestBuyQuote.worstCaseQuoteInfo(), selectedAssetUnitAmount: state.selectedAssetUnitAmount, ethUsdPrice: state.ethUsdPrice, isLoading: state.quoteRequestState === AsyncProcessState.Pending, + assetName: assetUtils.bestNameForAsset(state.selectedAsset), + baseCurrency: state.baseCurrency, }); +interface ConnectedDispatch extends Pick<OrderDetailsProps, DispatchProperties> {} +const mapDispatchToProps = (dispatch: Dispatch<Action>): ConnectedDispatch => ({ + onBaseCurrencySwitchEth: () => { + dispatch(actions.updateBaseCurrency(BaseCurrency.ETH)); + }, + onBaseCurrencySwitchUsd: () => { + dispatch(actions.updateBaseCurrency(BaseCurrency.USD)); + }, +}); + +export interface LatestBuyQuoteOrderDetailsProps {} export const LatestBuyQuoteOrderDetails: React.ComponentClass<LatestBuyQuoteOrderDetailsProps> = connect( mapStateToProps, + mapDispatchToProps, )(OrderDetails); diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index 77e3dec12..9d7a61fc7 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, AddressAndEthBalanceInWei, Asset, StandardSlidingPanelContent } from '../types'; +import { ActionsUnion, AddressAndEthBalanceInWei, Asset, BaseCurrency, StandardSlidingPanelContent } from '../types'; export interface PlainAction<T extends string> { type: T; @@ -43,6 +43,7 @@ export enum ActionTypes { RESET_AMOUNT = 'RESET_AMOUNT', OPEN_STANDARD_SLIDING_PANEL = 'OPEN_STANDARD_SLIDING_PANEL', CLOSE_STANDARD_SLIDING_PANEL = 'CLOSE_STANDARD_SLIDING_PANEL', + UPDATE_BASE_CURRENCY = 'UPDATE_BASE_CURRENCY', } export const actions = { @@ -72,4 +73,5 @@ export const actions = { openStandardSlidingPanel: (content: StandardSlidingPanelContent) => createAction(ActionTypes.OPEN_STANDARD_SLIDING_PANEL, content), closeStandardSlidingPanel: () => createAction(ActionTypes.CLOSE_STANDARD_SLIDING_PANEL), + updateBaseCurrency: (baseCurrency: BaseCurrency) => createAction(ActionTypes.UPDATE_BASE_CURRENCY, baseCurrency), }; diff --git a/packages/instant/src/redux/analytics_middleware.ts b/packages/instant/src/redux/analytics_middleware.ts index 3f7a51707..a86a16b1a 100644 --- a/packages/instant/src/redux/analytics_middleware.ts +++ b/packages/instant/src/redux/analytics_middleware.ts @@ -99,6 +99,9 @@ export const analyticsMiddleware: Middleware = store => next => middlewareAction analytics.trackInstallWalletModalClosed(); } break; + case ActionTypes.UPDATE_BASE_CURRENCY: + analytics.trackBaseCurrencyChanged(curState.baseCurrency); + analytics.addEventProperties({ baseCurrency: curState.baseCurrency }); } return nextAction; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts index 932eb93e9..884ab103d 100644 --- a/packages/instant/src/redux/async_data.ts +++ b/packages/instant/src/redux/async_data.ts @@ -4,7 +4,7 @@ import * as _ from 'lodash'; import { Dispatch } from 'redux'; import { BIG_NUMBER_ZERO } from '../constants'; -import { AccountState, ERC20Asset, OrderProcessState, ProviderState, QuoteFetchOrigin } from '../types'; +import { AccountState, BaseCurrency, ERC20Asset, OrderProcessState, ProviderState, QuoteFetchOrigin } from '../types'; import { analytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { buyQuoteUpdater } from '../util/buy_quote_updater'; @@ -24,7 +24,9 @@ export const asyncData = { const errorMessage = 'Error fetching ETH/USD price'; errorFlasher.flashNewErrorMessage(dispatch, errorMessage); dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + dispatch(actions.updateBaseCurrency(BaseCurrency.ETH)); errorReporter.report(e); + analytics.trackUsdPriceFailed(); } }, fetchAvailableAssetDatasAndDispatchToStore: async (state: State, dispatch: Dispatch) => { diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index a9a407b7d..8c13c9c72 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -14,6 +14,7 @@ import { Asset, AssetMetaData, AsyncProcessState, + BaseCurrency, DisplayStatus, Network, OrderProcessState, @@ -33,6 +34,7 @@ export interface DefaultState { latestErrorDisplayStatus: DisplayStatus; quoteRequestState: AsyncProcessState; standardSlidingPanelSettings: StandardSlidingPanelSettings; + baseCurrency: BaseCurrency; } // State that is required but needs to be derived from the props @@ -64,6 +66,7 @@ export const DEFAULT_STATE: DefaultState = { animationState: 'none', content: StandardSlidingPanelContent.None, }, + baseCurrency: BaseCurrency.USD, }; export const createReducer = (initialState: State) => { @@ -243,6 +246,11 @@ export const createReducer = (initialState: State) => { animationState: 'slidOut', }, }; + case ActionTypes.UPDATE_BASE_CURRENCY: + return { + ...state, + baseCurrency: action.data, + }; default: return state; } diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 1c7490e63..e7c920f36 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -26,6 +26,11 @@ export enum QuoteFetchOrigin { Heartbeat = 'Heartbeat', } +export enum BaseCurrency { + USD = 'USD', + ETH = 'ETH', +} + export interface SimulatedProgress { startTimeUnix: number; expectedEndTimeUnix: number; diff --git a/packages/instant/src/util/analytics.ts b/packages/instant/src/util/analytics.ts index e6128f857..6c63907dc 100644 --- a/packages/instant/src/util/analytics.ts +++ b/packages/instant/src/util/analytics.ts @@ -6,6 +6,7 @@ import { GIT_SHA, HEAP_ENABLED, INSTANT_DISCHARGE_TARGET, NODE_ENV, NPM_PACKAGE_ import { AffiliateInfo, Asset, + BaseCurrency, Network, OrderProcessState, OrderSource, @@ -37,6 +38,7 @@ enum EventNames { ACCOUNT_UNLOCK_REQUESTED = 'Account - Unlock Requested', ACCOUNT_UNLOCK_DENIED = 'Account - Unlock Denied', ACCOUNT_ADDRESS_CHANGED = 'Account - Address Changed', + BASE_CURRENCY_CHANGED = 'Base Currency - Changed', PAYMENT_METHOD_DROPDOWN_OPENED = 'Payment Method - Dropdown Opened', PAYMENT_METHOD_OPENED_ETHERSCAN = 'Payment Method - Opened Etherscan', PAYMENT_METHOD_COPIED_ADDRESS = 'Payment Method - Copied Address', @@ -47,6 +49,7 @@ enum EventNames { BUY_TX_SUBMITTED = 'Buy - Tx Submitted', BUY_TX_SUCCEEDED = 'Buy - Tx Succeeded', BUY_TX_FAILED = 'Buy - Tx Failed', + USD_PRICE_FETCH_FAILED = 'USD Price - Fetch Failed', INSTALL_WALLET_CLICKED = 'Install Wallet - Clicked', INSTALL_WALLET_MODAL_OPENED = 'Install Wallet - Modal - Opened', INSTALL_WALLET_MODAL_CLICKED_EXPLANATION = 'Install Wallet - Modal - Clicked Explanation', @@ -118,6 +121,7 @@ export interface AnalyticsEventOptions { selectedAssetSymbol?: string; selectedAssetData?: string; selectedAssetDecimals?: number; + baseCurrency?: string; } export enum TokenSelectorClosedVia { ClickedX = 'Clicked X', @@ -141,6 +145,7 @@ export const analytics = { window: Window, selectedAsset?: Asset, affiliateInfo?: AffiliateInfo, + baseCurrency?: BaseCurrency, ): AnalyticsEventOptions => { const affiliateAddress = affiliateInfo ? affiliateInfo.feeRecipient : 'none'; const affiliateFeePercent = affiliateInfo ? parseFloat(affiliateInfo.feePercentage.toFixed(4)) : 0; @@ -159,6 +164,7 @@ export const analytics = { selectedAssetName: selectedAsset ? selectedAsset.metaData.name : 'none', selectedAssetData: selectedAsset ? selectedAsset.assetData : 'none', instantEnvironment: INSTANT_DISCHARGE_TARGET || `Local ${NODE_ENV}`, + baseCurrency, }; return eventOptions; }, @@ -170,6 +176,8 @@ export const analytics = { trackAccountUnlockDenied: trackingEventFnWithoutPayload(EventNames.ACCOUNT_UNLOCK_DENIED), trackAccountAddressChanged: (address: string) => trackingEventFnWithPayload(EventNames.ACCOUNT_ADDRESS_CHANGED)({ address }), + trackBaseCurrencyChanged: (currencyChangedTo: BaseCurrency) => + trackingEventFnWithPayload(EventNames.BASE_CURRENCY_CHANGED)({ currencyChangedTo }), trackPaymentMethodDropdownOpened: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_DROPDOWN_OPENED), trackPaymentMethodOpenedEtherscan: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_OPENED_ETHERSCAN), trackPaymentMethodCopiedAddress: trackingEventFnWithoutPayload(EventNames.PAYMENT_METHOD_COPIED_ADDRESS), @@ -230,4 +238,5 @@ export const analytics = { fetchOrigin, }); }, + trackUsdPriceFailed: trackingEventFnWithoutPayload(EventNames.USD_PRICE_FETCH_FAILED), }; diff --git a/packages/instant/src/util/asset.ts b/packages/instant/src/util/asset.ts index 13f84ef74..faaeb7c22 100644 --- a/packages/instant/src/util/asset.ts +++ b/packages/instant/src/util/asset.ts @@ -2,6 +2,7 @@ import { AssetBuyerError } from '@0x/asset-buyer'; import { AssetProxyId, ObjectMap } from '@0x/types'; import * as _ from 'lodash'; +import { DEFAULT_UNKOWN_ASSET_NAME } from '../constants'; import { assetDataNetworkMapping } from '../data/asset_data_network_mapping'; import { Asset, AssetMetaData, ERC20Asset, Network, ZeroExInstantError } from '../types'; @@ -71,7 +72,7 @@ export const assetUtils = { } return metaData; }, - bestNameForAsset: (asset?: Asset, defaultName: string = '???'): string => { + bestNameForAsset: (asset?: Asset, defaultName: string = DEFAULT_UNKOWN_ASSET_NAME): string => { if (_.isUndefined(asset)) { return defaultName; } diff --git a/packages/instant/src/util/format.ts b/packages/instant/src/util/format.ts index e9c432b2f..4adb63e21 100644 --- a/packages/instant/src/util/format.ts +++ b/packages/instant/src/util/format.ts @@ -2,7 +2,7 @@ import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; -import { ETH_DECIMALS } from '../constants'; +import { BIG_NUMBER_ZERO, ETH_DECIMALS } from '../constants'; export const format = { ethBaseUnitAmount: ( @@ -20,24 +20,38 @@ export const format = { ethUnitAmount?: BigNumber, decimalPlaces: number = 4, defaultText: React.ReactNode = '0 ETH', + minUnitAmountToDisplay: BigNumber = new BigNumber('0.00001'), ): React.ReactNode => { if (_.isUndefined(ethUnitAmount)) { return defaultText; } - const roundedAmount = ethUnitAmount.round(decimalPlaces).toDigits(decimalPlaces); - return `${roundedAmount} ETH`; + let roundedAmount = ethUnitAmount.round(decimalPlaces).toDigits(decimalPlaces); + + if (roundedAmount.eq(BIG_NUMBER_ZERO) && ethUnitAmount.greaterThan(BIG_NUMBER_ZERO)) { + // Sometimes for small ETH amounts (i.e. 0.000045) the amount rounded to 4 decimalPlaces is 0 + // If that is the case, show to 1 significant digit + roundedAmount = new BigNumber(ethUnitAmount.toPrecision(1)); + } + + const displayAmount = + roundedAmount.greaterThan(BIG_NUMBER_ZERO) && roundedAmount.lessThan(minUnitAmountToDisplay) + ? `< ${minUnitAmountToDisplay.toString()}` + : roundedAmount.toString(); + + return `${displayAmount} ETH`; }, ethBaseUnitAmountInUsd: ( ethBaseUnitAmount?: BigNumber, ethUsdPrice?: BigNumber, decimalPlaces: number = 2, defaultText: React.ReactNode = '$0.00', + minUnitAmountToDisplay: BigNumber = new BigNumber('0.00001'), ): React.ReactNode => { if (_.isUndefined(ethBaseUnitAmount) || _.isUndefined(ethUsdPrice)) { return defaultText; } const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseUnitAmount, ETH_DECIMALS); - return format.ethUnitAmountInUsd(ethUnitAmount, ethUsdPrice, decimalPlaces); + return format.ethUnitAmountInUsd(ethUnitAmount, ethUsdPrice, decimalPlaces, minUnitAmountToDisplay); }, ethUnitAmountInUsd: ( ethUnitAmount?: BigNumber, @@ -48,7 +62,13 @@ export const format = { if (_.isUndefined(ethUnitAmount) || _.isUndefined(ethUsdPrice)) { return defaultText; } - return `$${ethUnitAmount.mul(ethUsdPrice).toFixed(decimalPlaces)}`; + const rawUsdPrice = ethUnitAmount.mul(ethUsdPrice); + const roundedUsdPrice = rawUsdPrice.toFixed(decimalPlaces); + if (roundedUsdPrice === '0.00' && rawUsdPrice.gt(BIG_NUMBER_ZERO)) { + return '<$0.01'; + } else { + return `$${roundedUsdPrice}`; + } }, ethAddress: (address: string): string => { return `0x${address.slice(2, 7)}…${address.slice(-5)}`; diff --git a/packages/instant/test/util/format.test.ts b/packages/instant/test/util/format.test.ts index fe0a63e6e..38bf356ec 100644 --- a/packages/instant/test/util/format.test.ts +++ b/packages/instant/test/util/format.test.ts @@ -41,6 +41,18 @@ describe('format', () => { it('converts BigNumber(5.3014059295032) to the string `5.301 ETH`', () => { expect(format.ethUnitAmount(BIG_NUMBER_IRRATIONAL)).toBe('5.301 ETH'); }); + it('shows 1 significant digit when rounded amount would be 0', () => { + expect(format.ethUnitAmount(new BigNumber(0.00003))).toBe('0.00003 ETH'); + expect(format.ethUnitAmount(new BigNumber(0.000034))).toBe('0.00003 ETH'); + expect(format.ethUnitAmount(new BigNumber(0.000035))).toBe('0.00004 ETH'); + }); + it('shows < 0.00001 when hits threshold', () => { + expect(format.ethUnitAmount(new BigNumber(0.000011))).toBe('0.00001 ETH'); + expect(format.ethUnitAmount(new BigNumber(0.00001))).toBe('0.00001 ETH'); + expect(format.ethUnitAmount(new BigNumber(0.000009))).toBe('< 0.00001 ETH'); + expect(format.ethUnitAmount(new BigNumber(0.0000000009))).toBe('< 0.00001 ETH'); + expect(format.ethUnitAmount(new BigNumber(0))).toBe('0 ETH'); + }); it('returns defaultText param when ethUnitAmount is not defined', () => { const defaultText = 'defaultText'; expect(format.ethUnitAmount(undefined, 4, defaultText)).toBe(defaultText); @@ -86,6 +98,12 @@ describe('format', () => { it('correctly formats 5.3014059295032 ETH to usd according to some price', () => { expect(format.ethUnitAmountInUsd(BIG_NUMBER_IRRATIONAL, BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$13.43'); }); + it('correctly formats amount that is less than 1 cent', () => { + expect(format.ethUnitAmountInUsd(new BigNumber(0.000001), BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('<$0.01'); + }); + it('correctly formats exactly 1 cent', () => { + expect(format.ethUnitAmountInUsd(new BigNumber(0.0039), BIG_NUMBER_FAKE_ETH_USD_PRICE)).toBe('$0.01'); + }); it('returns defaultText param when ethUnitAmountInUsd or ethUsdPrice is not defined', () => { const defaultText = 'defaultText'; expect(format.ethUnitAmountInUsd(undefined, undefined, 2, defaultText)).toBe(defaultText); |