aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/asset-buyer/CHANGELOG.json10
-rw-r--r--packages/asset-buyer/src/asset_buyer.ts33
-rw-r--r--packages/asset-buyer/src/utils/buy_quote_calculator.ts2
-rw-r--r--packages/instant/public/index.html10
-rw-r--r--packages/instant/src/components/buy_button.tsx22
-rw-r--r--packages/instant/src/components/buy_order_progress.tsx35
-rw-r--r--packages/instant/src/components/buy_order_state_buttons.tsx7
-rw-r--r--packages/instant/src/components/time_counter.tsx78
-rw-r--r--packages/instant/src/components/timed_progress_bar.tsx78
-rw-r--r--packages/instant/src/components/zero_ex_instant_container.tsx5
-rw-r--r--packages/instant/src/components/zero_ex_instant_provider.tsx8
-rw-r--r--packages/instant/src/constants.ts5
-rw-r--r--packages/instant/src/containers/selected_asset_buy_order_progress.ts13
-rw-r--r--packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts22
-rw-r--r--packages/instant/src/containers/selected_erc20_asset_amount_input.ts32
-rw-r--r--packages/instant/src/index.umd.ts3
-rw-r--r--packages/instant/src/redux/actions.ts13
-rw-r--r--packages/instant/src/redux/reducer.ts58
-rw-r--r--packages/instant/src/types.ts11
-rw-r--r--packages/instant/src/util/assert.ts10
-rw-r--r--packages/instant/src/util/gas_price_estimator.ts32
-rw-r--r--packages/instant/src/util/time.ts39
-rw-r--r--packages/instant/test/util/time.test.ts48
23 files changed, 515 insertions, 59 deletions
diff --git a/packages/asset-buyer/CHANGELOG.json b/packages/asset-buyer/CHANGELOG.json
index 6ba2a0fd9..0d71bb84d 100644
--- a/packages/asset-buyer/CHANGELOG.json
+++ b/packages/asset-buyer/CHANGELOG.json
@@ -14,6 +14,16 @@
{
"note": "No longer require that provided orders all have the same maker and taker asset data",
"pr": 1197
+ },
+ {
+ "note":
+ "Fix bug where `BuyQuoteInfo` objects could return `totalEthAmount` and `feeEthAmount` that were not whole numbers",
+ "pr": 1207
+ },
+ {
+ "note":
+ "Fix bug where default values for `AssetBuyer` public facing methods could get overriden by `undefined` values",
+ "pr": 1207
}
]
},
diff --git a/packages/asset-buyer/src/asset_buyer.ts b/packages/asset-buyer/src/asset_buyer.ts
index 49743404f..934410c55 100644
--- a/packages/asset-buyer/src/asset_buyer.ts
+++ b/packages/asset-buyer/src/asset_buyer.ts
@@ -90,10 +90,11 @@ export class AssetBuyer {
* @return An instance of AssetBuyer
*/
constructor(provider: Provider, orderProvider: OrderProvider, options: Partial<AssetBuyerOpts> = {}) {
- const { networkId, orderRefreshIntervalMs, expiryBufferSeconds } = {
- ...constants.DEFAULT_ASSET_BUYER_OPTS,
- ...options,
- };
+ const { networkId, orderRefreshIntervalMs, expiryBufferSeconds } = _.merge(
+ {},
+ constants.DEFAULT_ASSET_BUYER_OPTS,
+ options,
+ );
assert.isWeb3Provider('provider', provider);
assert.isValidOrderProvider('orderProvider', orderProvider);
assert.isNumber('networkId', networkId);
@@ -122,10 +123,11 @@ export class AssetBuyer {
assetBuyAmount: BigNumber,
options: Partial<BuyQuoteRequestOpts> = {},
): Promise<BuyQuote> {
- const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = {
- ...constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS,
- ...options,
- };
+ const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = _.merge(
+ {},
+ constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS,
+ options,
+ );
assert.isString('assetData', assetData);
assert.isBigNumber('assetBuyAmount', assetBuyAmount);
assert.isValidPercentage('feePercentage', feePercentage);
@@ -186,10 +188,11 @@ export class AssetBuyer {
buyQuote: BuyQuote,
options: Partial<BuyQuoteExecutionOpts> = {},
): Promise<string> {
- const { ethAmount, takerAddress, feeRecipient, gasLimit, gasPrice } = {
- ...constants.DEFAULT_BUY_QUOTE_EXECUTION_OPTS,
- ...options,
- };
+ const { ethAmount, takerAddress, feeRecipient, gasLimit, gasPrice } = _.merge(
+ {},
+ constants.DEFAULT_BUY_QUOTE_EXECUTION_OPTS,
+ options,
+ );
assert.isValidBuyQuote('buyQuote', buyQuote);
if (!_.isUndefined(ethAmount)) {
assert.isBigNumber('ethAmount', ethAmount);
@@ -198,6 +201,12 @@ export class AssetBuyer {
assert.isETHAddressHex('takerAddress', takerAddress);
}
assert.isETHAddressHex('feeRecipient', feeRecipient);
+ if (!_.isUndefined(gasLimit)) {
+ assert.isNumber('gasLimit', gasLimit);
+ }
+ if (!_.isUndefined(gasPrice)) {
+ assert.isBigNumber('gasPrice', gasPrice);
+ }
const { orders, feeOrders, feePercentage, assetBuyAmount, worstCaseQuoteInfo } = buyQuote;
// if no takerAddress is provided, try to get one from the provider
let finalTakerAddress;
diff --git a/packages/asset-buyer/src/utils/buy_quote_calculator.ts b/packages/asset-buyer/src/utils/buy_quote_calculator.ts
index f94ab3fa4..6a67ed1ed 100644
--- a/packages/asset-buyer/src/utils/buy_quote_calculator.ts
+++ b/packages/asset-buyer/src/utils/buy_quote_calculator.ts
@@ -119,7 +119,7 @@ function calculateQuoteInfo(
ethAmountToBuyZrx = findEthAmountNeededToBuyZrx(feeOrdersAndFillableAmounts, zrxAmountToBuyAsset);
}
/// find the eth amount needed to buy the affiliate fee
- const ethAmountToBuyAffiliateFee = ethAmountToBuyAsset.mul(feePercentage);
+ const ethAmountToBuyAffiliateFee = ethAmountToBuyAsset.mul(feePercentage).ceil();
const totalEthAmountWithoutAffiliateFee = ethAmountToBuyAsset.plus(ethAmountToBuyZrx);
const ethAmountTotal = totalEthAmountWithoutAffiliateFee.plus(ethAmountToBuyAffiliateFee);
// divide into the assetBuyAmount in order to find rate of makerAsset / WETH
diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html
index 7580ab132..96a703224 100644
--- a/packages/instant/public/index.html
+++ b/packages/instant/public/index.html
@@ -77,11 +77,21 @@
onClose: () => { console.log('0x Instant Closed') }
}
const liquiditySourceOverride = queryParams.getQueryParamValue('liquiditySource');
+ const feeRecipientOverride = queryParams.getQueryParamValue('feeRecipient');
+ const feePercentageOverride = +queryParams.getQueryParamValue('feePercentage');
+ let affiliateInfoOverride;
+ if (feeRecipientOverride !== undefined && feePercentageOverride !== undefined) {
+ affiliateInfoOverride = {
+ feeRecipient: feeRecipientOverride,
+ feePercentage: feePercentageOverride
+ };
+ }
const renderOptionsOverrides = {
liquiditySource: liquiditySourceOverride === 'provided' ? providedOrders : liquiditySourceOverride,
assetData: queryParams.getQueryParamValue('assetData'),
networkId: +queryParams.getQueryParamValue('networkId') || undefined,
defaultAssetBuyAmount: +queryParams.getQueryParamValue('defaultAssetBuyAmount') || undefined,
+ affiliateInfo: affiliateInfoOverride,
}
const renderOptions = Object.assign({}, renderOptionsDefaults, removeUndefined(renderOptionsOverrides));
zeroExInstant.render(renderOptions);
diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx
index 93bd8e635..12ac62601 100644
--- a/packages/instant/src/components/buy_button.tsx
+++ b/packages/instant/src/components/buy_button.tsx
@@ -1,10 +1,11 @@
import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
import * as _ from 'lodash';
import * as React from 'react';
+import { oc } from 'ts-optchain';
import { WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX } from '../constants';
import { ColorOption } from '../style/theme';
-import { ZeroExInstantError } from '../types';
+import { AffiliateInfo, ZeroExInstantError } from '../types';
import { getBestAddress } from '../util/address';
import { balanceUtil } from '../util/balance';
import { gasPriceEstimator } from '../util/gas_price_estimator';
@@ -16,10 +17,11 @@ import { Button, Text } from './ui';
export interface BuyButtonProps {
buyQuote?: BuyQuote;
assetBuyer?: AssetBuyer;
+ affiliateInfo?: AffiliateInfo;
onValidationPending: (buyQuote: BuyQuote) => void;
onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void;
onSignatureDenied: (buyQuote: BuyQuote) => void;
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
}
@@ -42,7 +44,7 @@ export class BuyButton extends React.Component<BuyButtonProps> {
}
private readonly _handleClick = async () => {
// The button is disabled when there is no buy quote anyway.
- const { buyQuote, assetBuyer } = this.props;
+ const { buyQuote, assetBuyer, affiliateInfo } = this.props;
if (_.isUndefined(buyQuote) || _.isUndefined(assetBuyer)) {
return;
}
@@ -57,9 +59,14 @@ export class BuyButton extends React.Component<BuyButtonProps> {
}
let txHash: string | undefined;
- const gasPrice = await gasPriceEstimator.getFastAmountInWeiAsync();
+ const gasInfo = await gasPriceEstimator.getGasInfoAsync();
+ const feeRecipient = oc(affiliateInfo).feeRecipient();
try {
- txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { takerAddress, gasPrice });
+ txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, {
+ feeRecipient,
+ takerAddress,
+ gasPrice: gasInfo.gasPriceInWei,
+ });
} catch (e) {
if (e instanceof Error) {
if (e.message === AssetBuyerError.SignatureRequestDenied) {
@@ -73,7 +80,9 @@ export class BuyButton extends React.Component<BuyButtonProps> {
throw e;
}
- this.props.onBuyProcessing(buyQuote, txHash);
+ const startTimeUnix = new Date().getTime();
+ const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs;
+ this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix);
try {
await web3Wrapper.awaitTransactionSuccessAsync(txHash);
} catch (e) {
@@ -83,6 +92,7 @@ export class BuyButton extends React.Component<BuyButtonProps> {
}
throw e;
}
+
this.props.onBuySuccess(buyQuote, txHash);
};
}
diff --git a/packages/instant/src/components/buy_order_progress.tsx b/packages/instant/src/components/buy_order_progress.tsx
new file mode 100644
index 000000000..e259e5606
--- /dev/null
+++ b/packages/instant/src/components/buy_order_progress.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+
+import { TimedProgressBar } from '../components/timed_progress_bar';
+
+import { TimeCounter } from '../components/time_counter';
+import { Container } from '../components/ui';
+import { OrderProcessState, OrderState } from '../types';
+
+export interface BuyOrderProgressProps {
+ buyOrderState: OrderState;
+}
+
+export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = props => {
+ const { buyOrderState } = props;
+
+ if (
+ buyOrderState.processState === OrderProcessState.PROCESSING ||
+ buyOrderState.processState === OrderProcessState.SUCCESS ||
+ buyOrderState.processState === OrderProcessState.FAILURE
+ ) {
+ const progress = buyOrderState.progress;
+ const hasEnded = buyOrderState.processState !== OrderProcessState.PROCESSING;
+ const expectedTimeMs = progress.expectedEndTimeUnix - progress.startTimeUnix;
+ return (
+ <Container padding="20px 20px 0px 20px" width="100%">
+ <Container marginBottom="5px">
+ <TimeCounter estimatedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} />
+ </Container>
+ <TimedProgressBar expectedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} />
+ </Container>
+ );
+ }
+
+ return null;
+};
diff --git a/packages/instant/src/components/buy_order_state_buttons.tsx b/packages/instant/src/components/buy_order_state_buttons.tsx
index 51e06a284..5c074a67a 100644
--- a/packages/instant/src/components/buy_order_state_buttons.tsx
+++ b/packages/instant/src/components/buy_order_state_buttons.tsx
@@ -2,7 +2,7 @@ import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer';
import * as React from 'react';
import { ColorOption } from '../style/theme';
-import { OrderProcessState, ZeroExInstantError } from '../types';
+import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types';
import { BuyButton } from './buy_button';
import { PlacingOrderButton } from './placing_order_button';
@@ -13,17 +13,17 @@ export interface BuyOrderStateButtonProps {
buyQuote?: BuyQuote;
buyOrderProcessingState: OrderProcessState;
assetBuyer?: AssetBuyer;
+ affiliateInfo?: AffiliateInfo;
onViewTransaction: () => void;
onValidationPending: (buyQuote: BuyQuote) => void;
onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void;
onSignatureDenied: (buyQuote: BuyQuote) => void;
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
onRetry: () => void;
}
-// TODO: rename to buttons
export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonProps> = props => {
if (props.buyOrderProcessingState === OrderProcessState.FAILURE) {
return (
@@ -51,6 +51,7 @@ export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonP
<BuyButton
buyQuote={props.buyQuote}
assetBuyer={props.assetBuyer}
+ affiliateInfo={props.affiliateInfo}
onValidationPending={props.onValidationPending}
onValidationFail={props.onValidationFail}
onSignatureDenied={props.onSignatureDenied}
diff --git a/packages/instant/src/components/time_counter.tsx b/packages/instant/src/components/time_counter.tsx
new file mode 100644
index 000000000..f9b68163c
--- /dev/null
+++ b/packages/instant/src/components/time_counter.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react';
+
+import { ONE_SECOND_MS } from '../constants';
+import { ColorOption } from '../style/theme';
+import { timeUtil } from '../util/time';
+
+import { Container } from './ui/container';
+import { Flex } from './ui/flex';
+import { Text } from './ui/text';
+
+export interface TimeCounterProps {
+ estimatedTimeMs: number;
+ hasEnded: boolean;
+}
+interface TimeCounterState {
+ elapsedSeconds: number;
+}
+
+export class TimeCounter extends React.Component<TimeCounterProps, TimeCounterState> {
+ public state = {
+ elapsedSeconds: 0,
+ };
+ private _timerId?: number;
+
+ public componentDidMount(): void {
+ this._setupTimerBasedOnProps();
+ }
+
+ public componentWillUnmount(): void {
+ this._clearTimer();
+ }
+
+ public componentDidUpdate(prevProps: TimeCounterProps): void {
+ if (prevProps.hasEnded !== this.props.hasEnded) {
+ this._setupTimerBasedOnProps();
+ }
+ }
+
+ public render(): React.ReactNode {
+ const estimatedTimeSeconds = this.props.estimatedTimeMs / ONE_SECOND_MS;
+ return (
+ <Flex justify="space-between">
+ <Container>
+ <Container marginRight="5px" display="inline">
+ <Text fontWeight={600} fontColor={ColorOption.grey}>
+ Est. Time
+ </Text>
+ </Container>
+ <Text fontColor={ColorOption.grey}>
+ ({timeUtil.secondsToHumanDescription(estimatedTimeSeconds)})
+ </Text>
+ </Container>
+ <Text fontColor={ColorOption.grey}>
+ Time: {timeUtil.secondsToStopwatchTime(this.state.elapsedSeconds)}
+ </Text>
+ </Flex>
+ );
+ }
+
+ private _setupTimerBasedOnProps(): void {
+ this.props.hasEnded ? this._clearTimer() : this._newTimer();
+ }
+
+ private _newTimer(): void {
+ this._clearTimer();
+ this._timerId = window.setInterval(() => {
+ this.setState({
+ elapsedSeconds: this.state.elapsedSeconds + 1,
+ });
+ }, ONE_SECOND_MS);
+ }
+
+ private _clearTimer(): void {
+ if (this._timerId) {
+ window.clearInterval(this._timerId);
+ }
+ }
+}
diff --git a/packages/instant/src/components/timed_progress_bar.tsx b/packages/instant/src/components/timed_progress_bar.tsx
new file mode 100644
index 000000000..f2a6f5745
--- /dev/null
+++ b/packages/instant/src/components/timed_progress_bar.tsx
@@ -0,0 +1,78 @@
+import * as _ from 'lodash';
+import * as React from 'react';
+
+import { PROGRESS_FINISH_ANIMATION_TIME_MS, PROGRESS_STALL_AT_WIDTH } from '../constants';
+import { ColorOption, keyframes, styled } from '../style/theme';
+
+import { Container } from './ui/container';
+
+export interface TimedProgressBarProps {
+ expectedTimeMs: number;
+ hasEnded: boolean;
+}
+
+/**
+ * Timed Progress Bar
+ * Goes from 0% -> PROGRESS_STALL_AT_WIDTH over time of expectedTimeMs
+ * When hasEnded set to true, goes to 100% through animation of PROGRESS_FINISH_ANIMATION_TIME_MS length of time
+ */
+export class TimedProgressBar extends React.Component<TimedProgressBarProps, {}> {
+ private readonly _barRef = React.createRef<HTMLDivElement>();
+
+ public render(): React.ReactNode {
+ const timedProgressProps = this._calculateTimedProgressProps();
+ return (
+ <Container width="100%" backgroundColor={ColorOption.lightGrey} borderRadius="6px">
+ <TimedProgress {...timedProgressProps} ref={this._barRef as any} />
+ </Container>
+ );
+ }
+
+ private _calculateTimedProgressProps(): TimedProgressProps {
+ if (this.props.hasEnded) {
+ if (!this._barRef.current) {
+ throw new Error('ended but no reference');
+ }
+ const fromWidth = `${this._barRef.current.offsetWidth}px`;
+ return {
+ timeMs: PROGRESS_FINISH_ANIMATION_TIME_MS,
+ fromWidth,
+ toWidth: '100%',
+ };
+ }
+
+ return {
+ timeMs: this.props.expectedTimeMs,
+ fromWidth: '0px',
+ toWidth: PROGRESS_STALL_AT_WIDTH,
+ };
+ }
+}
+
+const expandingWidthKeyframes = (fromWidth: string, toWidth: string) => {
+ return keyframes`
+ from {
+ width: ${fromWidth};
+ }
+ to {
+ width: ${toWidth};
+ }
+ `;
+};
+
+interface TimedProgressProps {
+ timeMs: number;
+ fromWidth: string;
+ toWidth: string;
+}
+
+export const TimedProgress =
+ styled.div <
+ TimedProgressProps >
+ `
+ background-color: ${props => props.theme[ColorOption.primaryColor]};
+ border-radius: 6px;
+ height: 6px;
+ animation: ${props => expandingWidthKeyframes(props.fromWidth, props.toWidth)}
+ ${props => props.timeMs}ms linear 1 forwards;
+ `;
diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx
index 009ad9b2a..f8e3935fb 100644
--- a/packages/instant/src/components/zero_ex_instant_container.tsx
+++ b/packages/instant/src/components/zero_ex_instant_container.tsx
@@ -4,13 +4,15 @@ import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order
import { LatestError } from '../containers/latest_error';
import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_buy_order_state_buttons';
import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading';
+
+import { SelectedAssetBuyOrderProgress } from '../containers/selected_asset_buy_order_progress';
+
import { ColorOption } from '../style/theme';
import { zIndex } from '../style/z_index';
import { SlideAnimationState } from './animations/slide_animation';
import { SlidingPanel } from './sliding_panel';
import { Container, Flex } from './ui';
-
export interface ZeroExInstantContainerProps {}
export interface ZeroExInstantContainerState {
tokenSelectionPanelAnimationState: SlideAnimationState;
@@ -36,6 +38,7 @@ export class ZeroExInstantContainer extends React.Component<ZeroExInstantContain
>
<Flex direction="column" justify="flex-start">
<SelectedAssetInstantHeading onSelectAssetClick={this._handleSymbolClick} />
+ <SelectedAssetBuyOrderProgress />
<LatestBuyQuoteOrderDetails />
<Container padding="20px" width="100%">
<SelectedAssetBuyOrderStateButtons />
diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx
index 9391c03f7..a09ab68dc 100644
--- a/packages/instant/src/components/zero_ex_instant_provider.tsx
+++ b/packages/instant/src/components/zero_ex_instant_provider.tsx
@@ -10,7 +10,7 @@ import { asyncData } from '../redux/async_data';
import { INITIAL_STATE, State } from '../redux/reducer';
import { store, Store } from '../redux/store';
import { fonts } from '../style/fonts';
-import { AssetMetaData, Network } from '../types';
+import { AffiliateInfo, AssetMetaData, Network } from '../types';
import { assetUtils } from '../util/asset';
import { errorFlasher } from '../util/error_flasher';
import { gasPriceEstimator } from '../util/gas_price_estimator';
@@ -29,9 +29,10 @@ export interface ZeroExInstantProviderRequiredProps {
}
export interface ZeroExInstantProviderOptionalProps {
- defaultAssetBuyAmount?: number;
+ defaultAssetBuyAmount: number;
additionalAssetMetaDataMap: ObjectMap<AssetMetaData>;
networkId: Network;
+ affiliateInfo: AffiliateInfo;
}
export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> {
@@ -66,6 +67,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider
? state.selectedAssetAmount
: new BigNumber(props.defaultAssetBuyAmount),
assetMetaDataMap: completeAssetMetaDataMap,
+ affiliateInfo: props.affiliateInfo,
};
return storeStateFromProps;
}
@@ -82,7 +84,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider
// warm up the gas price estimator cache just in case we can't
// grab the gas price estimate when submitting the transaction
// tslint:disable-next-line:no-floating-promises
- gasPriceEstimator.getFastAmountInWeiAsync();
+ gasPriceEstimator.getGasInfoAsync();
// tslint:disable-next-line:no-floating-promises
this._flashErrorIfWrongNetwork();
diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts
index 3e830ba00..15105b65c 100644
--- a/packages/instant/src/constants.ts
+++ b/packages/instant/src/constants.ts
@@ -5,6 +5,11 @@ export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer';
export const INJECTED_DIV_ID = 'zeroExInstant';
export const WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX = 'Transaction failed';
export const GWEI_IN_WEI = new BigNumber(1000000000);
+export const ONE_SECOND_MS = 1000;
+export const ONE_MINUTE_MS = ONE_SECOND_MS * 60;
export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6);
+export const DEFAULT_ESTIMATED_TRANSACTION_TIME_MS = ONE_MINUTE_MS * 2;
export const ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info';
export const COINBASE_API_BASE_URL = 'https://api.coinbase.com/v2';
+export const PROGRESS_STALL_AT_WIDTH = '95%';
+export const PROGRESS_FINISH_ANIMATION_TIME_MS = 200;
diff --git a/packages/instant/src/containers/selected_asset_buy_order_progress.ts b/packages/instant/src/containers/selected_asset_buy_order_progress.ts
new file mode 100644
index 000000000..7c8c24676
--- /dev/null
+++ b/packages/instant/src/containers/selected_asset_buy_order_progress.ts
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+
+import { BuyOrderProgress } from '../components/buy_order_progress';
+import { State } from '../redux/reducer';
+import { OrderState } from '../types';
+
+interface ConnectedState {
+ buyOrderState: OrderState;
+}
+const mapStateToProps = (state: State, _ownProps: {}): ConnectedState => ({
+ buyOrderState: state.buyOrderState,
+});
+export const SelectedAssetBuyOrderProgress = connect(mapStateToProps)(BuyOrderProgress);
diff --git a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts
index 500d6b88a..72d99f844 100644
--- a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts
+++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts
@@ -7,7 +7,7 @@ import { Dispatch } from 'redux';
import { BuyOrderStateButtons } from '../components/buy_order_state_buttons';
import { Action, actions } from '../redux/actions';
import { State } from '../redux/reducer';
-import { OrderProcessState, OrderState, ZeroExInstantError } from '../types';
+import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types';
import { errorFlasher } from '../util/error_flasher';
import { etherscanUtil } from '../util/etherscan';
@@ -15,13 +15,14 @@ interface ConnectedState {
buyQuote?: BuyQuote;
buyOrderProcessingState: OrderProcessState;
assetBuyer?: AssetBuyer;
+ affiliateInfo?: AffiliateInfo;
onViewTransaction: () => void;
}
interface ConnectedDispatch {
onValidationPending: (buyQuote: BuyQuote) => void;
onSignatureDenied: (buyQuote: BuyQuote) => void;
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void;
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void;
onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void;
onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void;
onRetry: () => void;
@@ -32,6 +33,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButt
buyOrderProcessingState: state.buyOrderState.processState,
assetBuyer: state.assetBuyer,
buyQuote: state.latestBuyQuote,
+ affiliateInfo: state.affiliateInfo,
onViewTransaction: () => {
if (
state.assetBuyer &&
@@ -56,24 +58,20 @@ const mapDispatchToProps = (
ownProps: SelectedAssetBuyOrderStateButtons,
): ConnectedDispatch => ({
onValidationPending: (buyQuote: BuyQuote) => {
- const newOrderState: OrderState = { processState: OrderProcessState.VALIDATING };
- dispatch(actions.updateBuyOrderState(newOrderState));
+ dispatch(actions.setBuyOrderStateValidating());
},
- onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => {
- const newOrderState: OrderState = { processState: OrderProcessState.PROCESSING, txHash };
- dispatch(actions.updateBuyOrderState(newOrderState));
+ onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => {
+ dispatch(actions.setBuyOrderStateProcessing(txHash, startTimeUnix, expectedEndTimeUnix));
},
- onBuySuccess: (buyQuote: BuyQuote, txHash: string) =>
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.SUCCESS, txHash })),
- onBuyFailure: (buyQuote: BuyQuote, txHash: string) =>
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.FAILURE, txHash })),
+ onBuySuccess: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateSuccess(txHash)),
+ onBuyFailure: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateFailure(txHash)),
onSignatureDenied: () => {
dispatch(actions.resetAmount());
const errorMessage = 'You denied this transaction';
errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
},
onValidationFail: (buyQuote, error) => {
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.NONE }));
+ dispatch(actions.setBuyOrderStateNone());
if (error === ZeroExInstantError.InsufficientETH) {
const errorMessage = "You don't have enough ETH";
errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
diff --git a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts
index 1a5609b12..12bc7040e 100644
--- a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts
+++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts
@@ -6,12 +6,13 @@ import * as _ from 'lodash';
import * as React from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
+import { oc } from 'ts-optchain';
import { ERC20AssetAmountInput } from '../components/erc20_asset_amount_input';
import { Action, actions } from '../redux/actions';
import { State } from '../redux/reducer';
import { ColorOption } from '../style/theme';
-import { ERC20Asset, OrderProcessState } from '../types';
+import { AffiliateInfo, ERC20Asset, OrderProcessState } from '../types';
import { assetUtils } from '../util/asset';
import { errorFlasher } from '../util/error_flasher';
@@ -26,10 +27,16 @@ interface ConnectedState {
value?: BigNumber;
asset?: ERC20Asset;
isDisabled: boolean;
+ affiliateInfo?: AffiliateInfo;
}
interface ConnectedDispatch {
- updateBuyQuote: (assetBuyer?: AssetBuyer, value?: BigNumber, asset?: ERC20Asset) => void;
+ updateBuyQuote: (
+ assetBuyer?: AssetBuyer,
+ value?: BigNumber,
+ asset?: ERC20Asset,
+ affiliateInfo?: AffiliateInfo,
+ ) => void;
}
interface ConnectedProps {
@@ -59,6 +66,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedERC20AssetAmountInputP
value: state.selectedAssetAmount,
asset: selectedAsset as ERC20Asset,
isDisabled,
+ affiliateInfo: state.affiliateInfo,
};
};
@@ -67,6 +75,7 @@ const updateBuyQuoteAsync = async (
dispatch: Dispatch<Action>,
asset: ERC20Asset,
assetAmount: BigNumber,
+ affiliateInfo?: AffiliateInfo,
): Promise<void> => {
// get a new buy quote.
const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals);
@@ -74,9 +83,10 @@ const updateBuyQuoteAsync = async (
// mark quote as pending
dispatch(actions.setQuoteRequestStatePending());
+ const feePercentage = oc(affiliateInfo).feePercentage();
let newBuyQuote: BuyQuote | undefined;
try {
- newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue);
+ newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue, { feePercentage });
} catch (error) {
dispatch(actions.setQuoteRequestStateFailure());
let errorMessage;
@@ -92,7 +102,11 @@ const updateBuyQuoteAsync = async (
const assetName = assetUtils.bestNameForAsset(asset, 'This asset');
errorMessage = `${assetName} is currently unavailable`;
}
- errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
+ if (!_.isUndefined(errorMessage)) {
+ errorFlasher.flashNewErrorMessage(dispatch, errorMessage);
+ } else {
+ throw error;
+ }
return;
}
// We have a successful new buy quote
@@ -107,19 +121,19 @@ const mapDispatchToProps = (
dispatch: Dispatch<Action>,
_ownProps: SelectedERC20AssetAmountInputProps,
): ConnectedDispatch => ({
- updateBuyQuote: (assetBuyer, value, asset) => {
+ updateBuyQuote: (assetBuyer, value, asset, affiliateInfo) => {
// Update the input
dispatch(actions.updateSelectedAssetAmount(value));
// invalidate the last buy quote.
dispatch(actions.updateLatestBuyQuote(undefined));
// reset our buy state
- dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.NONE }));
+ dispatch(actions.setBuyOrderStateNone());
- if (!_.isUndefined(value) && !_.isUndefined(asset) && !_.isUndefined(assetBuyer)) {
+ if (!_.isUndefined(value) && value.greaterThan(0) && !_.isUndefined(asset) && !_.isUndefined(assetBuyer)) {
// even if it's debounced, give them the illusion it's loading
dispatch(actions.setQuoteRequestStatePending());
// tslint:disable-next-line:no-floating-promises
- debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value);
+ debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value, affiliateInfo);
}
},
});
@@ -134,7 +148,7 @@ const mergeProps = (
asset: connectedState.asset,
value: connectedState.value,
onChange: (value, asset) => {
- connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset);
+ connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset, connectedState.affiliateInfo);
},
isDisabled: connectedState.isDisabled,
};
diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts
index b12e65485..806187a16 100644
--- a/packages/instant/src/index.umd.ts
+++ b/packages/instant/src/index.umd.ts
@@ -24,6 +24,9 @@ export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFA
if (!_.isUndefined(props.zIndex)) {
assert.isNumber('props.zIndex', props.zIndex);
}
+ if (!_.isUndefined(props.affiliateInfo)) {
+ assert.isValidaffiliateInfo('props.affiliateInfo', props.affiliateInfo);
+ }
const appendToIfExists = document.querySelector(selector);
assert.assert(!_.isNull(appendToIfExists), `Could not find div with selector: ${selector}`);
const appendTo = appendToIfExists as Element;
diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts
index a0781db19..16bda0208 100644
--- a/packages/instant/src/redux/actions.ts
+++ b/packages/instant/src/redux/actions.ts
@@ -23,7 +23,11 @@ function createAction<T extends string, P>(type: T, data?: P): PlainAction<T> |
export enum ActionTypes {
UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE',
UPDATE_SELECTED_ASSET_AMOUNT = 'UPDATE_SELECTED_ASSET_AMOUNT',
- UPDATE_BUY_ORDER_STATE = 'UPDATE_BUY_ORDER_STATE',
+ SET_BUY_ORDER_STATE_NONE = 'SET_BUY_ORDER_STATE_NONE',
+ SET_BUY_ORDER_STATE_VALIDATING = 'SET_BUY_ORDER_STATE_VALIDATING',
+ SET_BUY_ORDER_STATE_PROCESSING = 'SET_BUY_ORDER_STATE_PROCESSING',
+ SET_BUY_ORDER_STATE_FAILURE = 'SET_BUY_ORDER_STATE_FAILURE',
+ SET_BUY_ORDER_STATE_SUCCESS = 'SET_BUY_ORDER_STATE_SUCCESS',
UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE',
UPDATE_SELECTED_ASSET = 'UPDATE_SELECTED_ASSET',
SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING',
@@ -37,7 +41,12 @@ export enum ActionTypes {
export const actions = {
updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price),
updateSelectedAssetAmount: (amount?: BigNumber) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT, amount),
- updateBuyOrderState: (orderState: OrderState) => createAction(ActionTypes.UPDATE_BUY_ORDER_STATE, orderState),
+ setBuyOrderStateNone: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_NONE),
+ setBuyOrderStateValidating: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_VALIDATING),
+ setBuyOrderStateProcessing: (txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) =>
+ createAction(ActionTypes.SET_BUY_ORDER_STATE_PROCESSING, { txHash, startTimeUnix, expectedEndTimeUnix }),
+ setBuyOrderStateFailure: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_FAILURE, txHash),
+ setBuyOrderStateSuccess: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_SUCCESS, txHash),
updateLatestBuyQuote: (buyQuote?: BuyQuote) => createAction(ActionTypes.UPDATE_LATEST_BUY_QUOTE, buyQuote),
updateSelectedAsset: (assetData?: string) => createAction(ActionTypes.UPDATE_SELECTED_ASSET, assetData),
setQuoteRequestStatePending: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING),
diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts
index 20f682787..1b4fdabbc 100644
--- a/packages/instant/src/redux/reducer.ts
+++ b/packages/instant/src/redux/reducer.ts
@@ -6,6 +6,7 @@ import * as _ from 'lodash';
import { assetMetaDataMap } from '../data/asset_meta_data_map';
import {
+ AffiliateInfo,
Asset,
AssetMetaData,
AsyncProcessState,
@@ -30,6 +31,7 @@ export interface State {
quoteRequestState: AsyncProcessState;
latestErrorMessage?: string;
latestErrorDisplayStatus: DisplayStatus;
+ affiliateInfo?: AffiliateInfo;
}
export const INITIAL_STATE: State = {
@@ -42,6 +44,7 @@ export const INITIAL_STATE: State = {
latestErrorMessage: undefined,
latestErrorDisplayStatus: DisplayStatus.Hidden,
quoteRequestState: AsyncProcessState.NONE,
+ affiliateInfo: undefined,
};
export const reducer = (state: State = INITIAL_STATE, action: Action): State => {
@@ -82,11 +85,62 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State =>
latestBuyQuote: undefined,
quoteRequestState: AsyncProcessState.FAILURE,
};
- case ActionTypes.UPDATE_BUY_ORDER_STATE:
+ case ActionTypes.SET_BUY_ORDER_STATE_NONE:
return {
...state,
- buyOrderState: action.data,
+ buyOrderState: { processState: OrderProcessState.NONE },
+ };
+ case ActionTypes.SET_BUY_ORDER_STATE_VALIDATING:
+ return {
+ ...state,
+ buyOrderState: { processState: OrderProcessState.VALIDATING },
};
+ case ActionTypes.SET_BUY_ORDER_STATE_PROCESSING:
+ const processingData = action.data;
+ const { startTimeUnix, expectedEndTimeUnix } = processingData;
+ return {
+ ...state,
+ buyOrderState: {
+ processState: OrderProcessState.PROCESSING,
+ txHash: processingData.txHash,
+ progress: {
+ startTimeUnix,
+ expectedEndTimeUnix,
+ },
+ },
+ };
+ case ActionTypes.SET_BUY_ORDER_STATE_FAILURE:
+ const failureTxHash = action.data;
+ if ('txHash' in state.buyOrderState) {
+ if (state.buyOrderState.txHash === failureTxHash) {
+ const { txHash, progress } = state.buyOrderState;
+ return {
+ ...state,
+ buyOrderState: {
+ processState: OrderProcessState.FAILURE,
+ txHash,
+ progress,
+ },
+ };
+ }
+ }
+ return state;
+ case ActionTypes.SET_BUY_ORDER_STATE_SUCCESS:
+ const successTxHash = action.data;
+ if ('txHash' in state.buyOrderState) {
+ if (state.buyOrderState.txHash === successTxHash) {
+ const { txHash, progress } = state.buyOrderState;
+ return {
+ ...state,
+ buyOrderState: {
+ processState: OrderProcessState.SUCCESS,
+ txHash,
+ progress,
+ },
+ };
+ }
+ }
+ return state;
case ActionTypes.SET_ERROR_MESSAGE:
return {
...state,
diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts
index e92e96e9a..739556801 100644
--- a/packages/instant/src/types.ts
+++ b/packages/instant/src/types.ts
@@ -18,12 +18,18 @@ export enum OrderProcessState {
FAILURE = 'Failure',
}
+export interface SimulatedProgress {
+ startTimeUnix: number;
+ expectedEndTimeUnix: number;
+}
+
interface OrderStatePreTx {
processState: OrderProcessState.NONE | OrderProcessState.VALIDATING;
}
interface OrderStatePostTx {
processState: OrderProcessState.PROCESSING | OrderProcessState.SUCCESS | OrderProcessState.FAILURE;
txHash: string;
+ progress: SimulatedProgress;
}
export type OrderState = OrderStatePreTx | OrderStatePostTx;
@@ -76,3 +82,8 @@ export enum ZeroExInstantError {
AssetMetaDataNotAvailable = 'ASSET_META_DATA_NOT_AVAILABLE',
InsufficientETH = 'INSUFFICIENT_ETH',
}
+
+export interface AffiliateInfo {
+ feeRecipient: string;
+ feePercentage: number;
+}
diff --git a/packages/instant/src/util/assert.ts b/packages/instant/src/util/assert.ts
index 584d3d4b1..20f8ddaee 100644
--- a/packages/instant/src/util/assert.ts
+++ b/packages/instant/src/util/assert.ts
@@ -4,7 +4,7 @@ import { assetDataUtils } from '@0x/order-utils';
import { AssetProxyId, ObjectMap, SignedOrder } from '@0x/types';
import * as _ from 'lodash';
-import { AssetMetaData } from '../types';
+import { AffiliateInfo, AssetMetaData } from '../types';
export const assert = {
...sharedAssert,
@@ -41,4 +41,12 @@ export const assert = {
assert.isUri(`${variableName}.imageUrl`, metaData.imageUrl);
}
},
+ isValidaffiliateInfo(variableName: string, affiliateInfo: AffiliateInfo): void {
+ assert.isETHAddressHex(`${variableName}.recipientAddress`, affiliateInfo.feeRecipient);
+ assert.isNumber(`${variableName}.percentage`, affiliateInfo.feePercentage);
+ assert.assert(
+ affiliateInfo.feePercentage >= 0 && affiliateInfo.feePercentage <= 0.05,
+ `Expected ${variableName}.percentage to be between 0 and 0.05, but is ${affiliateInfo.feePercentage}`,
+ );
+ },
};
diff --git a/packages/instant/src/util/gas_price_estimator.ts b/packages/instant/src/util/gas_price_estimator.ts
index 336c4a3fa..6b15809a3 100644
--- a/packages/instant/src/util/gas_price_estimator.ts
+++ b/packages/instant/src/util/gas_price_estimator.ts
@@ -1,6 +1,11 @@
import { BigNumber, fetchAsync } from '@0x/utils';
-import { DEFAULT_GAS_PRICE, ETH_GAS_STATION_API_BASE_URL, GWEI_IN_WEI } from '../constants';
+import {
+ DEFAULT_ESTIMATED_TRANSACTION_TIME_MS,
+ DEFAULT_GAS_PRICE,
+ ETH_GAS_STATION_API_BASE_URL,
+ GWEI_IN_WEI,
+} from '../constants';
interface EthGasStationResult {
average: number;
@@ -16,18 +21,25 @@ interface EthGasStationResult {
safeLow: number;
}
-const fetchFastAmountInWeiAsync = async () => {
+interface GasInfo {
+ gasPriceInWei: BigNumber;
+ estimatedTimeMs: number;
+}
+
+const fetchFastAmountInWeiAsync = async (): Promise<GasInfo> => {
const res = await fetchAsync(`${ETH_GAS_STATION_API_BASE_URL}/json/ethgasAPI.json`);
const gasInfo = (await res.json()) as EthGasStationResult;
// Eth Gas Station result is gwei * 10
const gasPriceInGwei = new BigNumber(gasInfo.fast / 10);
- return gasPriceInGwei.mul(GWEI_IN_WEI);
+ // Time is in minutes
+ const estimatedTimeMs = gasInfo.fastWait * 60 * 1000; // Minutes to MS
+ return { gasPriceInWei: gasPriceInGwei.mul(GWEI_IN_WEI), estimatedTimeMs };
};
export class GasPriceEstimator {
- private _lastFetched?: BigNumber;
- public async getFastAmountInWeiAsync(): Promise<BigNumber> {
- let fetchedAmount: BigNumber | undefined;
+ private _lastFetched?: GasInfo;
+ public async getGasInfoAsync(): Promise<GasInfo> {
+ let fetchedAmount: GasInfo | undefined;
try {
fetchedAmount = await fetchFastAmountInWeiAsync();
} catch {
@@ -38,7 +50,13 @@ export class GasPriceEstimator {
this._lastFetched = fetchedAmount;
}
- return fetchedAmount || this._lastFetched || DEFAULT_GAS_PRICE;
+ return (
+ fetchedAmount ||
+ this._lastFetched || {
+ gasPriceInWei: DEFAULT_GAS_PRICE,
+ estimatedTimeMs: DEFAULT_ESTIMATED_TRANSACTION_TIME_MS,
+ }
+ );
}
}
export const gasPriceEstimator = new GasPriceEstimator();
diff --git a/packages/instant/src/util/time.ts b/packages/instant/src/util/time.ts
new file mode 100644
index 000000000..bfe69cad5
--- /dev/null
+++ b/packages/instant/src/util/time.ts
@@ -0,0 +1,39 @@
+const secondsToMinutesAndRemainingSeconds = (seconds: number): { minutes: number; remainingSeconds: number } => {
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds - minutes * 60;
+
+ return {
+ minutes,
+ remainingSeconds,
+ };
+};
+
+const padZero = (aNumber: number): string => {
+ return aNumber < 10 ? `0${aNumber}` : aNumber.toString();
+};
+
+export const timeUtil = {
+ // converts seconds to human readable version of seconds or minutes
+ secondsToHumanDescription: (seconds: number): string => {
+ const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds);
+
+ if (minutes === 0) {
+ const suffix = seconds > 1 ? 's' : '';
+ return `${seconds} second${suffix}`;
+ }
+
+ const minuteSuffix = minutes > 1 ? 's' : '';
+ const minuteText = `${minutes} minute${minuteSuffix}`;
+
+ const secondsSuffix = remainingSeconds > 1 ? 's' : '';
+ const secondsText = remainingSeconds === 0 ? '' : ` ${remainingSeconds} second${secondsSuffix}`;
+
+ return `${minuteText}${secondsText}`;
+ },
+ // converts seconds to stopwatch time (i.e. 05:30 and 00:30)
+ // only goes up to minutes, not hours
+ secondsToStopwatchTime: (seconds: number): string => {
+ const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds);
+ return `${padZero(minutes)}:${padZero(remainingSeconds)}`;
+ },
+};
diff --git a/packages/instant/test/util/time.test.ts b/packages/instant/test/util/time.test.ts
new file mode 100644
index 000000000..fcb4e1875
--- /dev/null
+++ b/packages/instant/test/util/time.test.ts
@@ -0,0 +1,48 @@
+import { timeUtil } from '../../src/util/time';
+
+describe('timeUtil', () => {
+ describe('secondsToHumanDescription', () => {
+ const numsToResults: {
+ [aNumber: number]: string;
+ } = {
+ 1: '1 second',
+ 59: '59 seconds',
+ 60: '1 minute',
+ 119: '1 minute 59 seconds',
+ 120: '2 minutes',
+ 121: '2 minutes 1 second',
+ 122: '2 minutes 2 seconds',
+ };
+
+ const nums = Object.keys(numsToResults);
+ nums.forEach(aNum => {
+ const numInt = parseInt(aNum, 10);
+ it(`should work for ${aNum} seconds`, () => {
+ const expectedResult = numsToResults[numInt];
+ expect(timeUtil.secondsToHumanDescription(numInt)).toEqual(expectedResult);
+ });
+ });
+ });
+ describe('secondsToStopwatchTime', () => {
+ const numsToResults: {
+ [aNumber: number]: string;
+ } = {
+ 1: '00:01',
+ 59: '00:59',
+ 60: '01:00',
+ 119: '01:59',
+ 120: '02:00',
+ 121: '02:01',
+ 2701: '45:01',
+ };
+
+ const nums = Object.keys(numsToResults);
+ nums.forEach(aNum => {
+ const numInt = parseInt(aNum, 10);
+ it(`should work for ${aNum} seconds`, () => {
+ const expectedResult = numsToResults[numInt];
+ expect(timeUtil.secondsToStopwatchTime(numInt)).toEqual(expectedResult);
+ });
+ });
+ });
+});