Swap Part 4 (#101)

### Re-implements:
* min/max validators on initial currency swap selection
* polling of order status
* timer that persists across refreshes via localStorage (computed based on `createdTime` and `validFor` amount)
* swap persists across refreshes once order is created.
* various type refactors

### New additions:
* *SimpleButton* (can be PRd separately on request)
* clear loading state after order create (via SimpleButton and font-awesome)
* buffers for non-BTC swaps (bity does not actually accept 0.01 BTC worth of ETH as they claim they do in their JSON response, so a magic number of 10% is added to the minimum).
This commit is contained in:
Daniel Ternyak 2017-07-31 18:14:30 -05:00 committed by GitHub
parent e3505fd958
commit a66337ac0a
30 changed files with 1433 additions and 723 deletions

View File

@ -2,11 +2,12 @@
/*** Shared types ***/
export type NOTIFICATION_LEVEL = 'danger' | 'warning' | 'success' | 'info';
export type INFINITY = 'infinity';
export type Notification = {
level: NOTIFICATION_LEVEL,
msg: string,
duration?: number
duration?: number | INFINITY
};
/*** Show Notification ***/

View File

@ -1,10 +1,27 @@
// @flow
/*** Change Step ***/
export type ChangeStepSwapAction = {
type: 'SWAP_STEP',
value: number
};
import type {
OriginKindSwapAction,
DestinationKindSwapAction,
OriginAmountSwapAction,
DestinationAmountSwapAction,
LoadBityRatesSucceededSwapAction,
DestinationAddressSwapAction,
BityOrderCreateSucceededSwapAction,
BityOrderCreateRequestedSwapAction,
OrderStatusSucceededSwapAction,
ChangeStepSwapAction,
Pairs,
RestartSwapAction,
LoadBityRatesRequestedSwapAction,
StopLoadBityRatesSwapAction,
BityOrderResponse,
BityOrderPostResponse,
OrderStatusRequestedSwapAction,
StopOrderTimerSwapAction,
StartOrderTimerSwapAction,
StartPollBityOrderStatusAction,
StopPollBityOrderStatusAction
} from './swapTypes';
export function changeStepSwap(value: number): ChangeStepSwapAction {
return {
@ -13,25 +30,6 @@ export function changeStepSwap(value: number): ChangeStepSwapAction {
};
}
/*** Change Reference Number ***/
export type ReferenceNumberSwapAction = {
type: 'SWAP_REFERENCE_NUMBER',
value: string
};
export function referenceNumberSwap(value: string): ReferenceNumberSwapAction {
return {
type: 'SWAP_REFERENCE_NUMBER',
value
};
}
/*** Change Origin Kind ***/
export type OriginKindSwapAction = {
type: 'SWAP_ORIGIN_KIND',
value: string
};
export function originKindSwap(value: string): OriginKindSwapAction {
return {
type: 'SWAP_ORIGIN_KIND',
@ -39,12 +37,6 @@ export function originKindSwap(value: string): OriginKindSwapAction {
};
}
/*** Change Destination Kind ***/
export type DestinationKindSwapAction = {
type: 'SWAP_DESTINATION_KIND',
value: string
};
export function destinationKindSwap(value: string): DestinationKindSwapAction {
return {
type: 'SWAP_DESTINATION_KIND',
@ -52,12 +44,6 @@ export function destinationKindSwap(value: string): DestinationKindSwapAction {
};
}
/*** Change Origin Amount ***/
export type OriginAmountSwapAction = {
type: 'SWAP_ORIGIN_AMOUNT',
value: ?number
};
export function originAmountSwap(value: ?number): OriginAmountSwapAction {
return {
type: 'SWAP_ORIGIN_AMOUNT',
@ -65,12 +51,6 @@ export function originAmountSwap(value: ?number): OriginAmountSwapAction {
};
}
/*** Change Destination Amount ***/
export type DestinationAmountSwapAction = {
type: 'SWAP_DESTINATION_AMOUNT',
value: ?number
};
export function destinationAmountSwap(
value: ?number
): DestinationAmountSwapAction {
@ -80,32 +60,15 @@ export function destinationAmountSwap(
};
}
/*** Update Bity Rates ***/
export type Pairs = {
ETHBTC: number,
ETHREP: number,
BTCETH: number,
BTCREP: number
};
export type BityRatesSwapAction = {
type: 'SWAP_UPDATE_BITY_RATES',
export function loadBityRatesSucceededSwap(
value: Pairs
};
export function updateBityRatesSwap(value: Pairs): BityRatesSwapAction {
): LoadBityRatesSucceededSwapAction {
return {
type: 'SWAP_UPDATE_BITY_RATES',
type: 'SWAP_LOAD_BITY_RATES_SUCCEEDED',
value
};
}
/*** Change Destination Address ***/
export type DestinationAddressSwapAction = {
type: 'SWAP_DESTINATION_ADDRESS',
value: ?string
};
export function destinationAddressSwap(
value: ?string
): DestinationAddressSwapAction {
@ -115,49 +78,92 @@ export function destinationAddressSwap(
};
}
/*** Restart ***/
export type RestartSwapAction = {
type: 'SWAP_RESTART'
};
export function restartSwap(): RestartSwapAction {
return {
type: 'SWAP_RESTART'
};
}
/*** Load Bity Rates ***/
export type LoadBityRatesSwapAction = {
type: 'SWAP_LOAD_BITY_RATES'
};
export function loadBityRatesSwap(): LoadBityRatesSwapAction {
export function loadBityRatesRequestedSwap(): LoadBityRatesRequestedSwapAction {
return {
type: 'SWAP_LOAD_BITY_RATES'
type: 'SWAP_LOAD_BITY_RATES_REQUESTED'
};
}
/*** Stop Loading Bity Rates ***/
export type StopLoadBityRatesSwapAction = {
type: 'SWAP_STOP_LOAD_BITY_RATES'
};
export function stopLoadBityRatesSwap(): StopLoadBityRatesSwapAction {
return {
type: 'SWAP_STOP_LOAD_BITY_RATES'
};
}
/*** Action Type Union ***/
export type SwapAction =
| ChangeStepSwapAction
| ReferenceNumberSwapAction
| OriginKindSwapAction
| DestinationKindSwapAction
| OriginAmountSwapAction
| DestinationAmountSwapAction
| BityRatesSwapAction
| DestinationAddressSwapAction
| RestartSwapAction
| LoadBityRatesSwapAction
| StopLoadBityRatesSwapAction;
export function orderTimeSwap(value: number) {
return {
type: 'SWAP_ORDER_TIME',
value
};
}
export function bityOrderCreateSucceededSwap(
payload: BityOrderPostResponse
): BityOrderCreateSucceededSwapAction {
return {
type: 'SWAP_BITY_ORDER_CREATE_SUCCEEDED',
payload
};
}
export function bityOrderCreateRequestedSwap(
amount: number,
destinationAddress: string,
pair: string,
mode: number = 0
): BityOrderCreateRequestedSwapAction {
return {
type: 'SWAP_ORDER_CREATE_REQUESTED',
payload: {
amount,
destinationAddress,
pair,
mode
}
};
}
export function orderStatusSucceededSwap(
payload: BityOrderResponse
): OrderStatusSucceededSwapAction {
return {
type: 'SWAP_BITY_ORDER_STATUS_SUCCEEDED',
payload
};
}
export function orderStatusRequestedSwap(): OrderStatusRequestedSwapAction {
return {
type: 'SWAP_BITY_ORDER_STATUS_REQUESTED'
};
}
export function startOrderTimerSwap(): StartOrderTimerSwapAction {
return {
type: 'SWAP_ORDER_START_TIMER'
};
}
export function stopOrderTimerSwap(): StopOrderTimerSwapAction {
return {
type: 'SWAP_ORDER_STOP_TIMER'
};
}
export function startPollBityOrderStatus(): StartPollBityOrderStatusAction {
return {
type: 'SWAP_START_POLL_BITY_ORDER_STATUS'
};
}
export function stopPollBityOrderStatus(): StopPollBityOrderStatusAction {
return {
type: 'SWAP_STOP_POLL_BITY_ORDER_STATUS'
};
}

128
common/actions/swapTypes.js Normal file
View File

@ -0,0 +1,128 @@
export type Pairs = {
ETHBTC: number,
ETHREP: number,
BTCETH: number,
BTCREP: number
};
export type OriginKindSwapAction = {
type: 'SWAP_ORIGIN_KIND',
value: string
};
export type DestinationKindSwapAction = {
type: 'SWAP_DESTINATION_KIND',
value: string
};
export type OriginAmountSwapAction = {
type: 'SWAP_ORIGIN_AMOUNT',
value: ?number
};
export type DestinationAmountSwapAction = {
type: 'SWAP_DESTINATION_AMOUNT',
value: ?number
};
export type LoadBityRatesSucceededSwapAction = {
type: 'SWAP_LOAD_BITY_RATES_SUCCEEDED',
value: Pairs
};
export type DestinationAddressSwapAction = {
type: 'SWAP_DESTINATION_ADDRESS',
value: ?number
};
export type RestartSwapAction = {
type: 'SWAP_RESTART'
};
export type LoadBityRatesRequestedSwapAction = {
type: 'SWAP_LOAD_BITY_RATES_REQUESTED'
};
export type ChangeStepSwapAction = {
type: 'SWAP_STEP',
value: number
};
export type StopLoadBityRatesSwapAction = {
type: 'SWAP_STOP_LOAD_BITY_RATES'
};
export type BityOrderCreateRequestedSwapAction = {
type: 'SWAP_ORDER_CREATE_REQUESTED',
payload: {
amount: number,
destinationAddress: string,
pair: string,
mode: number
}
};
type BityOrderInput = {
amount: string
};
type BityOrderOutput = {
amount: string
};
export type BityOrderResponse = {
status: string
};
export type BityOrderPostResponse = BityOrderResponse & {
payment_address: string,
status: string,
input: BityOrderInput,
output: BityOrderOutput,
timestamp_created: string,
validFor: number
};
export type BityOrderCreateSucceededSwapAction = {
type: 'SWAP_BITY_ORDER_CREATE_SUCCEEDED',
payload: BityOrderPostResponse
};
export type OrderStatusRequestedSwapAction = {
type: 'SWAP_BITY_ORDER_STATUS_REQUESTED',
payload: BityOrderResponse
};
export type OrderStatusSucceededSwapAction = {
type: 'SWAP_BITY_ORDER_STATUS_SUCCEEDED',
payload: BityOrderResponse
};
export type StartOrderTimerSwapAction = {
type: 'SWAP_ORDER_START_TIMER'
};
export type StopOrderTimerSwapAction = {
type: 'SWAP_ORDER_STOP_TIMER'
};
export type StartPollBityOrderStatusAction = {
type: 'SWAP_START_POLL_BITY_ORDER_STATUS'
};
export type StopPollBityOrderStatusAction = {
type: 'SWAP_STOP_POLL_BITY_ORDER_STATUS'
};
/*** Action Type Union ***/
export type SwapAction =
| ChangeStepSwapAction
| OriginKindSwapAction
| DestinationKindSwapAction
| OriginAmountSwapAction
| DestinationAmountSwapAction
| LoadBityRatesSucceededSwapAction
| DestinationAddressSwapAction
| RestartSwapAction
| LoadBityRatesRequestedSwapAction
| StopLoadBityRatesSwapAction
| BityOrderCreateRequestedSwapAction
| BityOrderCreateSucceededSwapAction
| BityOrderResponse
| OrderStatusSucceededSwapAction
| StartPollBityOrderStatusAction;

View File

@ -1,38 +1,18 @@
// @flow
import bityConfig from 'config/bity';
import {combineAndUpper} from 'utils/formatters'
import { checkHttpStatus, parseJSON } from './utils';
import { combineAndUpper } from 'utils/formatters';
function findRateFromBityRateList(rateObjects, pairName) {
function findRateFromBityRateList(rateObjects, pairName: string) {
return rateObjects.find(x => x.pair === pairName);
}
// FIXME better types
function _getRate(bityRates, origin: string, destination: string) {
const pairName = combineAndUpper(origin, destination);
function _getRate(bityRates, originKind: string, destinationKind: string) {
const pairName = combineAndUpper(originKind, destinationKind);
const rateObjects = bityRates.objects;
return findRateFromBityRateList(rateObjects, pairName);
}
/**
* Gives you multiple rates from Bitys API without making multiple API calls
* @param arrayOfOriginAndDestinationDicts - [{origin: 'BTC', destination: 'ETH'}, {origin: 'BTC', destination: 'REP}]
*/
function getMultipleRates(arrayOfOriginAndDestinationDicts) {
const mappedRates = {};
return _getAllRates().then(bityRates => {
arrayOfOriginAndDestinationDicts.forEach(each => {
const origin = each.origin;
const destination = each.destination;
const pairName = combineAndUpper(origin, destination);
const rate = _getRate(bityRates, origin, destination);
mappedRates[pairName] = parseFloat(rate.rate_we_sell);
});
return mappedRates;
});
// TODO - catch errors
}
export function getAllRates() {
const mappedRates = {};
return _getAllRates().then(bityRates => {
@ -42,11 +22,44 @@ export function getAllRates() {
});
return mappedRates;
});
// TODO - catch errors
}
export function postOrder(
amount: number,
destAddress: string,
mode: number,
pair: string
) {
return fetch(`${bityConfig.serverURL}/order`, {
method: 'post',
body: JSON.stringify({
amount,
destAddress,
mode,
pair
}),
headers: bityConfig.postConfig.headers
})
.then(checkHttpStatus)
.then(parseJSON);
}
export function getOrderStatus(orderid: string) {
return fetch(`${bityConfig.serverURL}/status`, {
method: 'POST',
body: JSON.stringify({
orderid
}),
headers: bityConfig.postConfig.headers
})
.then(checkHttpStatus)
.then(parseJSON);
}
function _getAllRates() {
return fetch(`${bityConfig.bityAPI}/v1/rate2/`).then(r => r.json());
return fetch(`${bityConfig.bityAPI}/v1/rate2/`)
.then(checkHttpStatus)
.then(parseJSON);
}
function requestStatus() {}
function requestOrderStatus() {}

View File

@ -1,117 +1,13 @@
// Request utils,
// feel free to replace with your code
// (get, post are used in ApiServices)
import { getLocalToken } from 'api/AuthSvc';
import config from 'config';
window.BASE_API = config.BASE_API;
function requestWrapper(method) {
return async function(url, data = null, params = {}) {
if (method === 'GET') {
// is it a GET?
// GET doesn't have data
params = data;
data = null;
} else if (data === Object(data)) {
// (data === Object(data)) === _.isObject(data)
data = JSON.stringify(data);
} else {
throw new Error(`XHR invalid, check ${method} on ${url}`);
}
// default params for fetch = method + (Content-Type)
let defaults = {
method: method,
headers: {
'Content-Type': 'application/json; charset=UTF-8'
}
};
// check that req url is relative and request was sent to our domain
if (url.match(/^https?:\/\//gi) > -1) {
let token = getLocalToken();
if (token) {
defaults.headers['Authorization'] = `JWT ${token}`;
}
url = window.BASE_API + url;
}
if (data) {
defaults.body = data;
}
let paramsObj = {
...defaults,
headers: { ...params, ...defaults.headers }
};
return await fetch(url, paramsObj).then(parseJSON).catch(err => {
console.error(err);
});
};
}
// middlewares
// parse fetch json, add ok property and return request result
/**
* 1. parse response
* 2. add "ok" property to result
* 3. return request result
* @param {Object} res - response from server
* @return {Object} response result with "ok" property
*/
async function parseJSON(res) {
let json;
try {
json = await res.json();
} catch (e) {
return { data: {}, ok: false };
}
// simplest validation ever, ahah :)
if (!res.ok) {
return { data: json, ok: false };
}
// resultOK - is a function with side effects
// It removes ok property from result object
return { data: json, ok: true };
}
export const get = requestWrapper('GET');
export const post = requestWrapper('POST');
export const put = requestWrapper('PUT');
export const patch = requestWrapper('PATCH');
export const del = requestWrapper('DELETE');
// USAGE:
// get('https://www.google.com', {
// Authorization: 'JWT LOL',
// headers: {
// 'Content-Type': 'text/html'
// }
// })
// FUNCTION WITH SIDE-EFFECTS
/**
* `parseJSON()` adds property "ok"
* that identicates that response is OK
*
* `resultOK`removes result.ok from result and returns "ok" property
* It widely used in `/actions/*`
* for choosing action to dispatch after request to API
*
* @param {Object} result - response result that
* @return {bool} - indicates was request successful or not
*/
export function resultOK(result) {
if (result) {
let ok = result.ok;
delete result.ok;
return ok; //look at parseJSON
export function checkHttpStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
} else {
return false;
let error = new Error(response.statusText);
error.response = response;
throw error;
}
}
export function parseJSON(response) {
return response.json();
}

View File

@ -74,10 +74,11 @@ export default class TabsOptions extends Component {
<ul className="Navigation-links">
{tabs.map((object, i) => {
// if the window pathname is the same or similar to the tab objects name, set the active toggle
const activeOrNot = location.pathname === object.link ||
const activeOrNot =
location.pathname === object.link ||
location.pathname.substring(1) === object.link
? 'is-active'
: '';
? 'is-active'
: '';
return (
<li
className={'Navigation-links-item'}

View File

@ -0,0 +1,60 @@
// @flow
import React, { Component } from 'react';
const DEFAULT_BUTTON_TYPE = 'primary';
const DEFAULT_BUTTON_SIZE = 'lg';
const Spinner = () => {
return <i className="fa fa-spinner fa-spin fa-fw" />;
};
type ButtonType =
| 'default'
| 'primary'
| 'success'
| 'info'
| 'warning'
| 'danger';
type ButtonSize = 'lg' | 'sm' | 'xs';
type Props = {
onClick: () => any,
text: string,
loading?: boolean,
disabled?: boolean,
loadingText?: string,
size?: ButtonSize,
type?: ButtonType
};
export default class SimpleButton extends Component {
props: Props;
computedClass = () => {
return `btn btn-${this.props.size || DEFAULT_BUTTON_TYPE} btn-${this.props
.type || DEFAULT_BUTTON_SIZE}`;
};
render() {
let { loading, disabled, loadingText, text, onClick } = this.props;
return (
<div>
<button
onClick={onClick}
disabled={loading || disabled}
className={this.computedClass()}
>
{loading
? <div>
<Spinner />
{` ${loadingText || text}`}
</div>
: <div>
{text}
</div>}
</button>
</div>
);
}
}

View File

@ -1,18 +1,39 @@
export default {
serverURL: 'https://bity.myetherapi.com',
bityAPI: 'https://bity.com/api',
decimals: 6,
ethExplorer: 'https://etherscan.io/tx/[[txHash]]',
btcExplorer: 'https://blockchain.info/tx/[[txHash]]',
validStatus: ['RCVE', 'FILL', 'CONF', 'EXEC'],
invalidStatus: ['CANC'],
mainPairs: ['REP', 'ETH'],
min: 0.01,
max: 3,
priceLoaded: false,
// while Bity is supposedly OK with any order that is at least 0.01 BTC Worth, the order will fail if you send 0.01 BTC worth of ETH.
// This is a bad magic number, but will suffice for now
ETHBuffer: 0.1, // percent higher/lower than 0.01 BTC worth
REPBuffer: 0.2, // percent higher/lower than 0.01 BTC worth
BTCMin: 0.01,
BTCMax: 3,
ETHMin: function(BTCETHRate: number) {
const ETHMin = BTCETHRate * this.BTCMin;
const ETHMinWithPadding = ETHMin + ETHMin * this.ETHBuffer;
return ETHMinWithPadding;
},
ETHMax: function(BTCETHRate: number) {
const ETHMax = BTCETHRate * this.BTCMax;
const ETHMaxWithPadding = ETHMax - ETHMax * this.ETHBuffer;
return ETHMaxWithPadding;
},
REPMin: function(BTCREPRate: number) {
const REPMin = BTCREPRate * this.BTCMin;
const REPMinWithPadding = REPMin + REPMin * this.REPBuffer;
return REPMinWithPadding;
},
REPMax: function(BTCREPRate: number) {
const REPMax = BTCREPRate * this.BTCMax;
const REPMaxWithPadding = REPMax - REPMax * this.ETHBuffer;
return REPMaxWithPadding;
},
postConfig: {
headers: {
'Content-Type': 'application/json; charse:UTF-8'
'Content-Type': 'application/json; charset:UTF-8'
}
}
};

View File

@ -1,155 +0,0 @@
import React, { Component } from 'react';
import translate from 'translations';
import { combineAndUpper } from 'utils/formatters';
import SimpleDropDown from 'components/ui/SimpleDropdown';
import type {
OriginKindSwapAction,
DestinationKindSwapAction,
OriginAmountSwapAction,
DestinationAmountSwapAction,
ChangeStepSwapAction
} from 'actions/swap';
export type StateProps = {
bityRates: {},
originAmount: ?number,
destinationAmount: ?number,
originKind: string,
destinationKind: string,
destinationKindOptions: String[],
originKindOptions: String[]
};
export type ActionProps = {
originKindSwap: (value: string) => OriginKindSwapAction,
destinationKindSwap: (value: string) => DestinationKindSwapAction,
originAmountSwap: (value: ?number) => OriginAmountSwapAction,
destinationAmountSwap: (value: ?number) => DestinationAmountSwapAction,
changeStepSwap: () => ChangeStepSwapAction
};
export default class CurrencySwap extends Component {
props: StateProps & ActionProps;
state = {
disabled: false
};
onClickStartSwap = () => {
this.props.changeStepSwap(2);
};
setOriginAndDestinationToNull = () => {
this.props.originAmountSwap(null);
this.props.destinationAmountSwap(null);
};
onChangeOriginAmount = (event: SyntheticInputEvent) => {
const amount = event.target.value;
let originAmountAsNumber = parseFloat(amount);
if (originAmountAsNumber) {
let pairName = combineAndUpper(
this.props.originKind,
this.props.destinationKind
);
let bityRate = this.props.bityRates[pairName];
this.props.originAmountSwap(originAmountAsNumber);
this.props.destinationAmountSwap(originAmountAsNumber * bityRate);
} else {
this.setOriginAndDestinationToNull();
}
};
onChangeDestinationAmount = (event: SyntheticInputEvent) => {
const amount = event.target.value;
let destinationAmountAsNumber = parseFloat(amount);
if (destinationAmountAsNumber) {
this.props.destinationAmountSwap(destinationAmountAsNumber);
let pairName = combineAndUpper(
this.props.destinationKind,
this.props.originKind
);
let bityRate = this.props.bityRates[pairName];
this.props.originAmountSwap(destinationAmountAsNumber * bityRate);
} else {
this.setOriginAndDestinationToNull();
}
};
onChangeDestinationKind = (event: SyntheticInputEvent) => {
let newDestinationKind = event.target.value;
this.props.destinationKindSwap(newDestinationKind);
};
onChangeOriginKind = (event: SyntheticInputEvent) => {
let newOriginKind = event.target.value;
this.props.originKindSwap(newOriginKind);
};
render() {
const {
originAmount,
destinationAmount,
originKind,
destinationKind,
destinationKindOptions,
originKindOptions
} = this.props;
return (
<article className="swap-panel">
<h1>
{translate('SWAP_init_1')}
</h1>
<input
className={`form-control ${this.props.originAmount !== '' &&
this.props.originAmount > 0
? 'is-valid'
: 'is-invalid'}`}
type="number"
placeholder="Amount"
value={originAmount || ''}
onChange={this.onChangeOriginAmount}
/>
<SimpleDropDown
value={originKind}
onChange={this.onChangeOriginKind.bind(this)}
options={originKindOptions}
/>
<h1>
{translate('SWAP_init_2')}
</h1>
<input
className={`form-control ${this.props.destinationAmount !== '' &&
this.props.destinationAmount > 0
? 'is-valid'
: 'is-invalid'}`}
type="number"
placeholder="Amount"
value={destinationAmount || ''}
onChange={this.onChangeDestinationAmount}
/>
<SimpleDropDown
value={destinationKind}
onChange={this.onChangeDestinationKind}
options={destinationKindOptions}
/>
<div className="col-xs-12 clearfix text-center">
<button
disabled={this.state.disabled}
onClick={this.onClickStartSwap}
className="btn btn-info btn-lg"
>
<span>
{translate('SWAP_init_CTA')}
</span>
</button>
</div>
</article>
);
}
}

View File

@ -0,0 +1,224 @@
import React, { Component } from 'react';
import translate from 'translations';
import { combineAndUpper } from 'utils/formatters';
import SimpleDropDown from 'components/ui/SimpleDropdown';
import SimpleButton from 'components/ui/SimpleButton';
import type {
OriginKindSwapAction,
DestinationKindSwapAction,
OriginAmountSwapAction,
DestinationAmountSwapAction,
ChangeStepSwapAction
} from 'actions/swapTypes';
import bityConfig from 'config/bity';
import { toFixedIfLarger } from 'utils/formatters';
export type StateProps = {
bityRates: {},
originAmount: ?number,
destinationAmount: ?number,
originKind: string,
destinationKind: string,
destinationKindOptions: String[],
originKindOptions: String[]
};
export type ActionProps = {
originKindSwap: (value: string) => OriginKindSwapAction,
destinationKindSwap: (value: string) => DestinationKindSwapAction,
originAmountSwap: (value: ?number) => OriginAmountSwapAction,
destinationAmountSwap: (value: ?number) => DestinationAmountSwapAction,
changeStepSwap: () => ChangeStepSwapAction,
showNotification: Function
};
export default class CurrencySwap extends Component {
props: StateProps & ActionProps;
state = {
disabled: true,
showedMinMaxError: false
};
isMinMaxValid = (amount, kind) => {
let bityMin;
let bityMax;
if (kind !== 'BTC') {
const bityPairRate = this.props.bityRates['BTC' + kind];
bityMin = bityConfig[kind + 'Min'](bityPairRate);
bityMax = bityConfig[kind + 'Max'](bityPairRate);
} else {
bityMin = bityConfig.BTCMin;
bityMax = bityConfig.BTCMax;
}
let higherThanMin = amount >= bityMin;
let lowerThanMax = amount <= bityMax;
return higherThanMin && lowerThanMax;
};
isDisabled = (originAmount, originKind, destinationAmount) => {
const hasOriginAmountAndDestinationAmount =
originAmount && destinationAmount;
const minMaxIsValid = this.isMinMaxValid(originAmount, originKind);
return !(hasOriginAmountAndDestinationAmount && minMaxIsValid);
};
setDisabled(originAmount, originKind, destinationAmount) {
const disabled = this.isDisabled(
originAmount,
originKind,
destinationAmount
);
if (disabled && originAmount && !this.state.showedMinMaxError) {
const { bityRates } = this.props;
const ETHMin = bityConfig.ETHMin(bityRates.BTCETH);
const ETHMax = bityConfig.ETHMax(bityRates.BTCETH);
const REPMin = bityConfig.REPMax(bityRates.BTCREP);
const notificationMessage = `
Minimum amount ${bityConfig.BTCMin} BTC,
${toFixedIfLarger(ETHMin, 3)} ETH.
Max amount ${bityConfig.BTCMax} BTC,
${toFixedIfLarger(ETHMax, 3)} ETH, or
${toFixedIfLarger(REPMin, 3)} REP
`;
this.setState(
{
disabled: disabled,
showedMinMaxError: true
},
() => {
this.props.showNotification('danger', notificationMessage, 10000);
}
);
} else {
this.setState({
disabled: disabled
});
}
}
onClickStartSwap = () => {
this.props.changeStepSwap(2);
};
setOriginAndDestinationToNull = () => {
this.props.originAmountSwap(null);
this.props.destinationAmountSwap(null);
this.setDisabled(null, this.props.originKind, null);
};
onChangeOriginAmount = (event: SyntheticInputEvent) => {
const { destinationKind, originKind } = this.props;
const amount = event.target.value;
let originAmountAsNumber = parseFloat(amount);
if (originAmountAsNumber || originAmountAsNumber === 0) {
let pairName = combineAndUpper(originKind, destinationKind);
let bityRate = this.props.bityRates[pairName];
this.props.originAmountSwap(originAmountAsNumber);
let destinationAmount = originAmountAsNumber * bityRate;
this.props.destinationAmountSwap(destinationAmount);
this.setDisabled(originAmountAsNumber, originKind, destinationAmount);
} else {
this.setOriginAndDestinationToNull();
}
};
onChangeDestinationAmount = (event: SyntheticInputEvent) => {
const { destinationKind, originKind } = this.props;
const amount = event.target.value;
let destinationAmountAsNumber = parseFloat(amount);
if (destinationAmountAsNumber || destinationAmountAsNumber === 0) {
this.props.destinationAmountSwap(destinationAmountAsNumber);
let pairNameReversed = combineAndUpper(destinationKind, originKind);
let bityRate = this.props.bityRates[pairNameReversed];
let originAmount = destinationAmountAsNumber * bityRate;
this.props.originAmountSwap(originAmount, originKind);
this.setDisabled(originAmount, originKind, destinationAmountAsNumber);
} else {
this.setOriginAndDestinationToNull();
}
};
onChangeDestinationKind = (event: SyntheticInputEvent) => {
let newDestinationKind = event.target.value;
this.props.destinationKindSwap(newDestinationKind);
};
onChangeOriginKind = (event: SyntheticInputEvent) => {
let newOriginKind = event.target.value;
this.props.originKindSwap(newOriginKind);
};
render() {
const {
originAmount,
destinationAmount,
originKind,
destinationKind,
destinationKindOptions,
originKindOptions
} = this.props;
return (
<article className="swap-panel">
<h1>
{translate('SWAP_init_1')}
</h1>
<input
className={`form-control ${originAmount !== '' &&
this.isMinMaxValid(originAmount, originKind)
? 'is-valid'
: 'is-invalid'}`}
type="number"
placeholder="Amount"
value={
parseFloat(originAmount) === 0 ? originAmount : originAmount || ''
}
onChange={this.onChangeOriginAmount}
/>
<SimpleDropDown
value={originKind}
onChange={this.onChangeOriginKind.bind(this)}
options={originKindOptions}
/>
<h1>
{translate('SWAP_init_2')}
</h1>
<input
className={`form-control ${destinationAmount !== '' &&
this.isMinMaxValid(originAmount, originKind)
? 'is-valid'
: 'is-invalid'}`}
type="number"
placeholder="Amount"
value={
parseFloat(destinationAmount) === 0
? destinationAmount
: destinationAmount || ''
}
onChange={this.onChangeDestinationAmount}
/>
<SimpleDropDown
value={destinationKind}
onChange={this.onChangeDestinationKind}
options={destinationKindOptions}
/>
<div className="col-xs-12 clearfix text-center">
<SimpleButton
onClick={this.onClickStartSwap}
text={translate('SWAP_init_CTA')}
disabled={this.state.disabled}
/>
</div>
</article>
);
}
}

View File

@ -2,7 +2,7 @@
import React, { Component } from 'react';
import translate from 'translations';
import { toFixedIfLarger } from 'utils/formatters';
import type { Pairs } from 'actions/swap';
import type { Pairs } from 'actions/swapTypes';
import { bityReferralURL } from 'config/data';
import bityLogoWhite from 'assets/images/logo-bity-white.svg';

View File

@ -0,0 +1,83 @@
import React, { Component } from 'react';
import type {
LoadBityRatesRequestedSwapAction,
RestartSwapAction,
StopLoadBityRatesSwapAction
} from 'actions/swap';
import SwapProgress from './SwapProgress';
import PaymentInfo from './PaymentInfo';
type ReduxStateProps = {
destinationAddress: string,
destinationKind: string,
originKind: string,
originAmount: ?number,
destinationAmount: ?number,
isPostingOrder: boolean,
reference: string,
secondsRemaining: ?number,
paymentAddress: ?string,
orderStatus: ?string
};
type ReduxActionProps = {
loadBityRatesRequestedSwap: () => LoadBityRatesRequestedSwapAction,
restartSwap: () => RestartSwapAction,
stopLoadBityRatesSwap: () => StopLoadBityRatesSwapAction,
startOrderTimerSwap: Function,
startPollBityOrderStatus: Function,
stopOrderTimerSwap: Function,
stopPollBityOrderStatus: Function,
showNotification: Function
};
export default class PartThree extends Component {
props: ReduxActionProps & ReduxStateProps;
componentDidMount() {
this.props.startPollBityOrderStatus();
this.props.startOrderTimerSwap();
}
componentWillUnmount() {
this.props.stopOrderTimerSwap();
this.props.stopPollBityOrderStatus();
}
render() {
let {
// STATE
originAmount,
originKind,
destinationKind,
paymentAddress,
orderStatus,
destinationAddress,
outputTx,
// ACTIONS
showNotification
} = this.props;
let SwapProgressProps = {
originKind,
destinationKind,
orderStatus,
showNotification,
destinationAddress,
outputTx
};
const PaymentInfoProps = {
originKind,
originAmount,
paymentAddress
};
return (
<div>
<SwapProgress {...SwapProgressProps} />
<PaymentInfo {...PaymentInfoProps} />
</div>
);
}
}

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
import translate from 'translations';
export type Props = {
originKind: string,
originAmount: string,
paymentAddress: string
};
export default class PaymentInfo extends Component {
props: Props;
render() {
return (
<section className="row text-center">
<h1>
<span>
{translate('SWAP_order_CTA')}
</span>
<strong>
{this.props.originAmount} {this.props.originKind}{' '}
</strong>
<span>
{translate('SENDModal_Content_2')}
</span>
<br />
<strong className="mono text-primary">
{this.props.paymentAddress}
</strong>
</h1>
</section>
);
}
}

View File

@ -4,13 +4,18 @@ import type {
DestinationAddressSwapAction,
ChangeStepSwapAction,
StopLoadBityRatesSwapAction,
ReferenceNumberSwapAction
} from 'actions/swap';
BityOrderCreateRequestedSwapAction
} from 'actions/swapTypes';
import { donationAddressMap } from 'config/data';
import { isValidBTCAddress, isValidETHAddress } from 'libs/validators';
import translate from 'translations';
import { combineAndUpper } from 'utils/formatters';
import SimpleButton from 'components/ui/SimpleButton';
export type StateProps = {
isPostingOrder: boolean,
originAmount: number,
originKind: string,
destinationKind: string,
destinationAddress: string
};
@ -19,7 +24,12 @@ export type ActionProps = {
destinationAddressSwap: (value: ?string) => DestinationAddressSwapAction,
changeStepSwap: (value: number) => ChangeStepSwapAction,
stopLoadBityRatesSwap: () => StopLoadBityRatesSwapAction,
referenceNumberSwap: (value: string) => ReferenceNumberSwapAction
bityOrderCreateRequestedSwap: (
amount: number,
destinationAddress: string,
pair: string,
mode: ?number
) => BityOrderCreateRequestedSwapAction
};
export default class ReceivingAddress extends Component {
@ -31,14 +41,15 @@ export default class ReceivingAddress extends Component {
};
onClickPartTwoComplete = () => {
this.props.stopLoadBityRatesSwap();
// temporarily here for testing purposes. will live in saga
this.props.referenceNumberSwap('');
this.props.changeStepSwap(3);
this.props.bityOrderCreateRequestedSwap(
this.props.originAmount,
this.props.destinationAddress,
combineAndUpper(this.props.originKind, this.props.destinationKind)
);
};
render() {
const { destinationKind, destinationAddress } = this.props;
const { destinationKind, destinationAddress, isPostingOrder } = this.props;
let validAddress;
// TODO - find better pattern here once currencies move beyond BTC, ETH, REP
if (this.props.destinationKind === 'BTC') {
@ -72,15 +83,12 @@ export default class ReceivingAddress extends Component {
</div>
</section>
<section className="row text-center">
<button
disabled={!validAddress}
<SimpleButton
text={translate('SWAP_start_CTA')}
onClick={this.onClickPartTwoComplete}
className="btn btn-primary btn-lg"
>
<span>
{translate('SWAP_start_CTA')}
</span>
</button>
disabled={!validAddress}
loading={isPostingOrder}
/>
</section>
</section>
</article>

View File

@ -1,139 +0,0 @@
// @flow
import React, { Component } from 'react';
import { toFixedIfLarger } from 'utils/formatters';
import translate from 'translations';
import type { RestartSwapAction } from 'actions/swap';
import bityLogo from 'assets/images/logo-bity.svg';
import { bityReferralURL } from 'config/data';
export type StateProps = {
timeRemaining: string,
originAmount: number,
originKind: string,
destinationKind: string,
destinationAmount: number,
referenceNumber: string
};
export type ActionProps = {
restartSwap: () => RestartSwapAction
};
export default class SwapInfoHeader extends Component {
props: StateProps & ActionProps;
computedOriginDestinationRatio = () => {
return toFixedIfLarger(
this.props.destinationAmount / this.props.originAmount,
6
);
};
isExpanded = () => {
const { referenceNumber, timeRemaining, restartSwap } = this.props;
return referenceNumber && timeRemaining && restartSwap;
};
computedClass = () => {
if (this.isExpanded()) {
return 'col-sm-3 order-info';
} else {
return 'col-sm-4 order-info';
}
};
render() {
const {
referenceNumber,
timeRemaining,
originAmount,
destinationAmount,
originKind,
destinationKind,
restartSwap
} = this.props;
return (
<div>
<section className="row text-center">
<div className="col-xs-3 text-left">
<button className="btn btn-danger btn-xs" onClick={restartSwap}>
Start New Swap
</button>
</div>
<h5 className="col-xs-6">
{translate('SWAP_information')}
</h5>
<div className="col-xs-3">
<a
className="link"
href={bityReferralURL}
target="_blank"
rel="noopener"
>
<img
className="pull-right"
src={bityLogo}
width={100}
height={38}
/>
</a>
</div>
</section>
<section className="row order-info-wrap">
{/*Amount to send */}
{!this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{` ${toFixedIfLarger(originAmount, 6)} ${originKind}`}
</h4>
<p>
{translate('SEND_amount')}
</p>
</div>}
{/* Reference Number*/}
{this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{referenceNumber}
</h4>
<p>
{translate('SWAP_ref_num')}
</p>
</div>}
{/*Time remaining*/}
{this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{timeRemaining}
</h4>
<p>
{translate('SWAP_time')}
</p>
</div>}
{/*Amount to Receive*/}
<div className={this.computedClass()}>
<h4>
{` ${toFixedIfLarger(destinationAmount, 6)} ${destinationKind}`}
</h4>
<p>
{translate('SWAP_rec_amt')}
</p>
</div>
{/*Your rate*/}
<div className={this.computedClass()}>
<h4>
{` ${this.computedOriginDestinationRatio()} ${originKind}/${destinationKind} `}
</h4>
<p>
{translate('SWAP_your_rate')}
</p>
</div>
</section>
</div>
);
}
}

View File

@ -0,0 +1,164 @@
// @flow
import React, { Component } from 'react';
import translate from 'translations';
import type { RestartSwapAction } from 'actions/swapTypes';
import bityLogo from 'assets/images/logo-bity.svg';
import { bityReferralURL } from 'config/data';
import { toFixedIfLarger } from 'utils/formatters';
export type SwapInfoHeaderTitleProps = {
restartSwap: () => RestartSwapAction
};
class SwapInfoHeaderTitle extends Component {
props: SwapInfoHeaderTitleProps;
render() {
return (
<section className="row text-center">
<div className="col-xs-3 text-left">
<button
className="btn btn-danger btn-xs"
onClick={this.props.restartSwap}
>
Start New Swap
</button>
</div>
<h5 className="col-xs-6">
{translate('SWAP_information')}
</h5>
<div className="col-xs-3">
<a
className="link"
href={bityReferralURL}
target="_blank"
rel="noopener"
>
<img
className="pull-right"
src={bityLogo}
width={100}
height={38}
/>
</a>
</div>
</section>
);
}
}
export type SwapInfoHeaderProps = {
originAmount: number,
originKind: string,
destinationKind: string,
destinationAmount: number,
reference: string,
secondsRemaining: ?number,
restartSwap: () => RestartSwapAction
};
export default class SwapInfoHeader extends Component {
props: SwapInfoHeaderProps;
computedOriginDestinationRatio = () => {
return this.props.destinationAmount / this.props.originAmount;
};
isExpanded = () => {
const { reference, restartSwap } = this.props;
return reference && restartSwap;
};
computedClass = () => {
if (this.isExpanded()) {
return 'col-sm-3 order-info';
} else {
return 'col-sm-4 order-info';
}
};
formattedTime = () => {
const { secondsRemaining } = this.props;
if (secondsRemaining || secondsRemaining === 0) {
let minutes = Math.floor(secondsRemaining / 60);
let seconds = secondsRemaining - minutes * 60;
minutes = minutes < 10 ? '0' + minutes : minutes;
seconds = seconds < 10 ? '0' + seconds : seconds;
return minutes + ':' + seconds;
} else {
throw Error('secondsRemaining must be a number');
}
};
render() {
const {
reference,
originAmount,
destinationAmount,
originKind,
destinationKind,
restartSwap
} = this.props;
return (
<div>
<SwapInfoHeaderTitle restartSwap={restartSwap} />
<section className="row order-info-wrap">
{/*Amount to send*/}
{!this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{` ${originAmount} ${originKind}`}
</h4>
<p>
{translate('SEND_amount')}
</p>
</div>}
{/*Reference Number*/}
{this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{reference}
</h4>
<p>
{translate('SWAP_ref_num')}
</p>
</div>}
{/*Time remaining*/}
{this.isExpanded() &&
<div className={this.computedClass()}>
<h4>
{this.formattedTime()}
</h4>
<p>
{translate('SWAP_time')}
</p>
</div>}
{/*Amount to Receive*/}
<div className={this.computedClass()}>
<h4>
{` ${destinationAmount} ${destinationKind}`}
</h4>
<p>
{translate('SWAP_rec_amt')}
</p>
</div>
{/*Your rate*/}
<div className={this.computedClass()}>
<h4>
{` ${toFixedIfLarger(
this.computedOriginDestinationRatio()
)} ${originKind}/${destinationKind} `}
</h4>
<p>
{translate('SWAP_your_rate')}
</p>
</div>
</section>
</div>
);
}
}

View File

@ -1,79 +0,0 @@
//flow
import React, { Component } from 'react';
import translate from 'translations';
export type StateProps = {
numberOfConfirmations: number,
destinationKind: string,
originKind: string,
orderStep: number
};
export default class SwapProgress extends Component {
props: StateProps;
computedClass(i: number) {
const { orderStep } = this.props;
let cssClass = 'progress-item';
if (orderStep > i) {
cssClass += ' progress-true';
} else if (i === orderStep) {
cssClass += ' progress-active';
}
return cssClass;
}
render() {
const { numberOfConfirmations, destinationKind, originKind } = this.props;
return (
<section className="row swap-progress">
<div className="sep" />
<div className={this.computedClass(1)}>
<div className="progress-circle">
<i>1</i>
</div>
<p>
{translate('SWAP_progress_1')}
</p>
</div>
<div className={this.computedClass(2)}>
<div className="progress-circle">
<i>2</i>
</div>
<p>
<span>{translate('SWAP_progress_2')}</span>
{originKind}...
</p>
</div>
<div className={this.computedClass(3)}>
<div className="progress-circle">
<i>3</i>
</div>
<p>
{originKind} <span>{translate('SWAP_progress_3')}</span>
</p>
</div>
<div className={this.computedClass(4)}>
<div className="progress-circle">
<i>4</i>
</div>
<p>
<span>Sending your </span>
{destinationKind}
<br />
<small>
Waiting for {numberOfConfirmations} confirmations...
</small>
</p>
</div>
<div className={this.computedClass(5)}>
<div className="progress-circle">
<i>5</i>
</div>
<p>Order Complete</p>
</div>
</section>
);
}
}

View File

@ -0,0 +1,146 @@
//flow
import React, { Component } from 'react';
import translate from 'translations';
import bityConfig from 'config/bity';
export type Props = {
destinationKind: string,
destinationAddress: string,
outputTx: string,
originKind: string,
orderStatus: string,
// actions
showNotification: Function
};
export default class SwapProgress extends Component {
constructor(props) {
super(props);
this.state = {
hasShownViewTx: false
};
}
props: Props;
componentDidMount() {
this.showNotification();
}
showNotification = () => {
const { hasShownViewTx } = this.state;
const {
destinationKind,
outputTx,
showNotification,
orderStatus
} = this.props;
if (orderStatus === 'FILL') {
if (!hasShownViewTx) {
let linkElement;
let link;
// everything but BTC is a token
if (destinationKind !== 'BTC') {
link = bityConfig.ethExplorer.replace('[[txHash]]', outputTx);
linkElement = `<a href="${link}" target='_blank' rel='noopener'> View your transaction </a>`;
// BTC uses a different explorer
} else {
link = bityConfig.btcExplorer.replace('[[txHash]]', outputTx);
linkElement = `<a href="${link}" target='_blank' rel='noopener'> View your transaction </a>`;
}
this.setState({ hasShownViewTx: true }, () => {
showNotification('success', linkElement);
});
}
}
};
computedClass = (step: number) => {
const { orderStatus } = this.props;
let cssClass = 'progress-item';
switch (orderStatus) {
case 'OPEN':
if (step < 2) {
return cssClass + ' progress-true';
} else if (step === 2) {
return cssClass + ' progress-active';
} else {
return cssClass;
}
case 'RCVE':
if (step < 4) {
return cssClass + ' progress-true';
} else if (step === 4) {
return cssClass + ' progress-active';
} else {
return cssClass;
}
case 'FILL':
cssClass += ' progress-true';
return cssClass;
case 'CANC':
return cssClass;
default:
return cssClass;
}
};
render() {
const { destinationKind, originKind } = this.props;
const numberOfConfirmations = originKind === 'BTC' ? '3' : '10';
return (
<section className="row swap-progress">
<div className="sep" />
<div className={this.computedClass(1)}>
<div className="progress-circle">
<i>1</i>
</div>
<p>
{translate('SWAP_progress_1')}
</p>
</div>
<div className={this.computedClass(2)}>
<div className="progress-circle">
<i>2</i>
</div>
<p>
<span>{translate('SWAP_progress_2')}</span>
{originKind}...
</p>
</div>
<div className={this.computedClass(3)}>
<div className="progress-circle">
<i>3</i>
</div>
<p>
{originKind} <span>{translate('SWAP_progress_3')}</span>
</p>
</div>
<div className={this.computedClass(4)}>
<div className="progress-circle">
<i>4</i>
</div>
<p>
<span>Sending your </span>
{destinationKind}
<br />
<small>
Waiting for {numberOfConfirmations} confirmations...
</small>
</p>
</div>
<div className={this.computedClass(5)}>
<div className="progress-circle">
<i>5</i>
</div>
<p>Order Complete</p>
</div>
</section>
);
}
}

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { showNotification } from 'actions/notifications';
import * as swapActions from 'actions/swap';
import type {
ChangeStepSwapAction,
@ -7,16 +8,20 @@ import type {
DestinationKindSwapAction,
OriginAmountSwapAction,
DestinationAmountSwapAction,
LoadBityRatesSwapAction,
LoadBityRatesRequestedSwapAction,
DestinationAddressSwapAction,
RestartSwapAction,
StopLoadBityRatesSwapAction
StopLoadBityRatesSwapAction,
BityOrderCreateRequestedSwapAction,
StartPollBityOrderStatusAction,
StopOrderTimerSwapAction,
StopPollBityOrderStatusAction
} from 'actions/swap';
import CurrencySwap from './components/CurrencySwap';
import CurrentRates from './components/CurrentRates';
import ReceivingAddress from './components/ReceivingAddress';
import SwapInfoHeader from './components/SwapInfoHeader';
import SwapProgress from './components/SwapProgress';
import PartThree from './components/PartThree';
type ReduxStateProps = {
step: string,
@ -25,16 +30,16 @@ type ReduxStateProps = {
originKind: string,
destinationKindOptions: String[],
originKindOptions: String[],
bityRates: boolean,
bityRates: {},
originAmount: ?number,
destinationAmount: ?number,
isPostingOrder: boolean,
isFetchingRates: boolean,
// PART 3
referenceNumber: string,
timeRemaining: string,
numberOfConfirmation: number,
orderStep: number,
orderStarted: boolean
bityOrder: {},
secondsRemaining: ?number,
paymentAddress: ?string,
orderStatus: ?string,
outputTx: ?string
};
type ReduxActionProps = {
@ -43,12 +48,19 @@ type ReduxActionProps = {
destinationKindSwap: (value: string) => DestinationKindSwapAction,
originAmountSwap: (value: ?number) => OriginAmountSwapAction,
destinationAmountSwap: (value: ?number) => DestinationAmountSwapAction,
loadBityRatesSwap: () => LoadBityRatesSwapAction,
loadBityRatesRequestedSwap: () => LoadBityRatesRequestedSwapAction,
destinationAddressSwap: (value: ?string) => DestinationAddressSwapAction,
restartSwap: () => RestartSwapAction,
stopLoadBityRatesSwap: () => StopLoadBityRatesSwapAction,
// PART 3 (IGNORE FOR NOW)
referenceNumberSwap: typeof swapActions.referenceNumberSwap
bityOrderCreateRequestedSwap: (
amount: number,
destinationAddress: string,
pair: string,
mode: number
) => BityOrderCreateRequestedSwapAction,
startPollBityOrderStatus: () => StartPollBityOrderStatusAction,
stopOrderTimerSwap: () => StopOrderTimerSwapAction,
stopPollBityOrderStatus: () => StopPollBityOrderStatusAction
};
class Swap extends Component {
@ -56,7 +68,7 @@ class Swap extends Component {
componentDidMount() {
// TODO: Use `isFetchingRates` to show a loader
this.props.loadBityRatesSwap();
this.props.loadBityRatesRequestedSwap();
}
componentWillUnmount() {
@ -75,10 +87,12 @@ class Swap extends Component {
originKindOptions,
destinationAddress,
step,
referenceNumber,
timeRemaining,
numberOfConfirmations,
orderStep,
bityOrder,
secondsRemaining,
paymentAddress,
orderStatus,
isPostingOrder,
outputTx,
// ACTIONS
restartSwap,
stopLoadBityRatesSwap,
@ -88,35 +102,44 @@ class Swap extends Component {
originAmountSwap,
destinationAmountSwap,
destinationAddressSwap,
referenceNumberSwap
bityOrderCreateRequestedSwap,
showNotification,
startOrderTimerSwap,
startPollBityOrderStatus,
stopOrderTimerSwap,
stopPollBityOrderStatus
} = this.props;
const { reference } = bityOrder;
let ReceivingAddressProps = {
isPostingOrder,
originAmount,
originKind,
destinationKind,
destinationAddressSwap,
destinationAddress,
stopLoadBityRatesSwap,
changeStepSwap,
referenceNumberSwap
bityOrderCreateRequestedSwap
};
let SwapInfoHeaderProps = {
referenceNumber,
timeRemaining,
reference,
secondsRemaining,
originAmount,
originKind,
destinationKind,
destinationAmount,
restartSwap,
numberOfConfirmations,
orderStep
orderStatus
};
const { ETHBTC, ETHREP, BTCETH, BTCREP } = bityRates;
const CurrentRatesProps = { ETHBTC, ETHREP, BTCETH, BTCREP };
const CurrencySwapProps = {
showNotification,
bityRates,
originAmount,
destinationAmount,
@ -131,6 +154,25 @@ class Swap extends Component {
changeStepSwap
};
const PaymentInfoProps = {
originKind,
originAmount,
paymentAddress
};
const PartThreeProps = {
...SwapInfoHeaderProps,
...PaymentInfoProps,
reference,
startOrderTimerSwap,
startPollBityOrderStatus,
stopOrderTimerSwap,
stopPollBityOrderStatus,
showNotification,
destinationAddress,
outputTx
};
return (
<section className="container" style={{ minHeight: '50%' }}>
<div className="tab-content">
@ -143,7 +185,7 @@ class Swap extends Component {
{(step === 2 || step === 3) &&
<SwapInfoHeader {...SwapInfoHeaderProps} />}
{step === 2 && <ReceivingAddress {...ReceivingAddressProps} />}
{step === 3 && <SwapProgress {...SwapInfoHeaderProps} />}
{step === 3 && <PartThree {...PartThreeProps} />}
</main>
</div>
</section>
@ -153,6 +195,10 @@ class Swap extends Component {
function mapStateToProps(state) {
return {
outputTx: state.swap.outputTx,
isPostingOrder: state.swap.isPostingOrder,
orderStatus: state.swap.orderStatus,
paymentAddress: state.swap.paymentAddress,
step: state.swap.step,
destinationAddress: state.swap.destinationAddress,
originAmount: state.swap.originAmount,
@ -162,13 +208,12 @@ function mapStateToProps(state) {
destinationKindOptions: state.swap.destinationKindOptions,
originKindOptions: state.swap.originKindOptions,
bityRates: state.swap.bityRates,
referenceNumber: state.swap.referenceNumber,
timeRemaining: state.swap.timeRemaining,
numberOfConfirmations: state.swap.numberOfConfirmations,
orderStep: state.swap.orderStep,
orderStarted: state.swap.orderStarted,
bityOrder: state.swap.bityOrder,
secondsRemaining: state.swap.secondsRemaining,
isFetchingRates: state.swap.isFetchingRates
};
}
export default connect(mapStateToProps, swapActions)(Swap);
export default connect(mapStateToProps, { ...swapActions, showNotification })(
Swap
);

View File

@ -1,5 +1,6 @@
// Application styles must come first in order, to allow for overrides
import 'assets/styles/etherwallet-master.less';
import 'font-awesome/scss/font-awesome.scss';
import React from 'react';
import { render } from 'react-dom';

View File

@ -1,12 +1,14 @@
// @flow
import { combineAndUpper } from 'utils/formatters';
import type { SwapAction } from 'actions/swap';
import type { SwapAction } from 'actions/swapTypes';
import without from 'lodash/without';
export const ALL_CRYPTO_KIND_OPTIONS = ['BTC', 'ETH', 'REP'];
const DEFAULT_ORIGIN_KIND = 'BTC';
const DEFAULT_DESTINATION_KIND = 'ETH';
type State = {
originAmount: number,
destinationAmount: number,
originAmount: ?number,
destinationAmount: ?number,
originKind: string,
destinationKind: string,
destinationKindOptions: Array<string>,
@ -14,32 +16,37 @@ type State = {
step: number,
bityRates: Object,
destinationAddress: string,
referenceNumber: string,
timeRemaining: string,
numberOfConfirmations: ?number,
orderStep: ?number,
isFetchingRates: boolean
isFetchingRates: ?boolean,
secondsRemaining: ?number,
outputTx: ?string,
isPostingOrder: ?boolean,
orderStatus: ?string,
orderTimestampCreatedISOString: ?string,
paymentAddress: ?string,
validFor: ?number,
orderId: string
};
export const INITIAL_STATE: State = {
originAmount: 0,
destinationAmount: 0,
originKind: 'BTC',
destinationKind: 'ETH',
destinationKindOptions: ALL_CRYPTO_KIND_OPTIONS.filter(element => {
return element !== 'BTC';
}),
originKindOptions: ALL_CRYPTO_KIND_OPTIONS.filter(element => {
return element !== 'REP';
}),
originAmount: null,
destinationAmount: null,
originKind: DEFAULT_ORIGIN_KIND,
destinationKind: DEFAULT_DESTINATION_KIND,
destinationKindOptions: without(ALL_CRYPTO_KIND_OPTIONS, DEFAULT_ORIGIN_KIND),
originKindOptions: without(ALL_CRYPTO_KIND_OPTIONS, 'REP'),
step: 1,
bityRates: {},
destinationAddress: '',
referenceNumber: '',
timeRemaining: '',
numberOfConfirmations: null,
orderStep: null,
isFetchingRates: false
bityOrder: {},
isFetchingRates: null,
secondsRemaining: null,
outputTx: null,
isPostingOrder: false,
orderStatus: null,
orderTimestampCreatedISOString: null,
paymentAddress: null,
validFor: null,
orderId: null
};
const buildDestinationAmount = (
@ -50,7 +57,7 @@ const buildDestinationAmount = (
) => {
let pairName = combineAndUpper(originKind, destinationKind);
let bityRate = bityRates[pairName];
return originAmount * bityRate;
return originAmount ? originAmount * bityRate : 0;
};
const buildDestinationKind = (
@ -58,7 +65,7 @@ const buildDestinationKind = (
destinationKind: string
): string => {
if (originKind === destinationKind) {
return ALL_CRYPTO_KIND_OPTIONS.filter(element => element !== originKind)[0];
return without(ALL_CRYPTO_KIND_OPTIONS, originKind)[0];
} else {
return destinationKind;
}
@ -75,10 +82,7 @@ export function swap(state: State = INITIAL_STATE, action: SwapAction) {
...state,
originKind: action.value,
destinationKind: newDestinationKind,
destinationKindOptions: ALL_CRYPTO_KIND_OPTIONS.filter(element => {
// $FlowFixMe
return element !== action.value;
}),
destinationKindOptions: without(ALL_CRYPTO_KIND_OPTIONS, action.value),
destinationAmount: buildDestinationAmount(
state.originAmount,
action.value,
@ -109,7 +113,7 @@ export function swap(state: State = INITIAL_STATE, action: SwapAction) {
...state,
destinationAmount: action.value
};
case 'SWAP_UPDATE_BITY_RATES':
case 'SWAP_LOAD_BITY_RATES_SUCCEEDED':
return {
...state,
bityRates: {
@ -134,16 +138,48 @@ export function swap(state: State = INITIAL_STATE, action: SwapAction) {
...INITIAL_STATE,
bityRates: state.bityRates
};
case 'SWAP_REFERENCE_NUMBER':
case 'SWAP_ORDER_CREATE_REQUESTED':
return {
...state,
referenceNumber: '2341asdfads',
timeRemaining: '2:30',
numberOfConfirmations: 3,
orderStep: 2
isPostingOrder: true
};
case 'SWAP_ORDER_CREATE_FAILED':
return {
...state,
isPostingOrder: false
};
case 'SWAP_BITY_ORDER_CREATE_SUCCEEDED':
return {
...state,
bityOrder: {
...action.payload
},
isPostingOrder: false,
originAmount: parseFloat(action.payload.input.amount),
destinationAmount: parseFloat(action.payload.output.amount),
secondsRemaining: action.payload.validFor, // will get update
validFor: action.payload.validFor, // to build from local storage
orderTimestampCreatedISOString: action.payload.timestamp_created,
paymentAddress: action.payload.payment_address,
orderStatus: action.payload.status,
orderId: action.payload.id
};
case 'SWAP_BITY_ORDER_STATUS_SUCCEEDED':
return {
...state,
outputTx: action.payload.output.reference,
orderStatus:
action.payload.output.status === 'FILL'
? action.payload.output.status
: action.payload.input.status
};
case 'SWAP_ORDER_TIME':
return {
...state,
secondsRemaining: action.value
};
case 'SWAP_LOAD_BITY_RATES':
case 'SWAP_LOAD_BITY_RATES_REQUESTED':
return {
...state,
isFetchingRates: true
@ -156,7 +192,6 @@ export function swap(state: State = INITIAL_STATE, action: SwapAction) {
};
default:
(action: empty);
return state;
}
}

View File

@ -1,8 +1,23 @@
import bity from './bity';
import {
postBityOrderSaga,
bityTimeRemaining,
pollBityOrderStatusSaga
} from './swap/orders';
import { getBityRatesSaga } from './swap/rates';
import contracts from './contracts';
import ens from './ens';
import notifications from './notifications';
import rates from './rates';
import wallet from './wallet';
export default { bity, contracts, ens, notifications, rates, wallet };
export default {
bityTimeRemaining,
postBityOrderSaga,
pollBityOrderStatusSaga,
getBityRatesSaga,
contracts,
ens,
notifications,
rates,
wallet
};

View File

@ -9,7 +9,7 @@ function* handleNotification(action?: ShowNotificationAction) {
if (!action) return;
const { duration } = action.payload;
// show forever
if (duration === 0) {
if (duration === 0 || duration === 'infinity') {
return;
}

178
common/sagas/swap/orders.js Normal file
View File

@ -0,0 +1,178 @@
import { showNotification } from 'actions/notifications';
import { delay } from 'redux-saga';
import { postOrder, getOrderStatus } from 'api/bity';
import {
call,
put,
fork,
take,
cancel,
select,
cancelled,
takeEvery,
Effect
} from 'redux-saga/effects';
import {
orderTimeSwap,
bityOrderCreateSucceededSwap,
stopLoadBityRatesSwap,
changeStepSwap,
orderStatusRequestedSwap,
orderStatusSucceededSwap,
startOrderTimerSwap,
startPollBityOrderStatus,
stopPollBityOrderStatus
} from 'actions/swap';
import moment from 'moment';
export const getSwap = state => state.swap;
const ONE_SECOND = 1000;
const TEN_SECONDS = ONE_SECOND * 10;
const BITY_TIMEOUT_MESSAGE = `
Time has run out.
If you have already sent, please wait 1 hour.
If your order has not be processed after 1 hour,
please press the orange 'Issue with your Swap?' button.
`;
export function* pollBityOrderStatus(): Generator<Effect, void, any> {
try {
let swap = yield select(getSwap);
while (true) {
yield put(orderStatusRequestedSwap());
const orderStatus = yield call(getOrderStatus, swap.orderId);
if (orderStatus.error) {
yield put(
showNotification(
'danger',
`Bity Error: ${orderStatus.msg}`,
TEN_SECONDS
)
);
} else {
yield put(orderStatusSucceededSwap(orderStatus.data));
yield call(delay, ONE_SECOND * 5);
swap = yield select(getSwap);
if (swap === 'CANC') {
break;
}
}
}
} finally {
if (yield cancelled()) {
// TODO - implement request cancel if needed
// yield put(actions.requestFailure('Request cancelled!'))
}
}
}
export function* pollBityOrderStatusSaga(): Generator<Effect, void, any> {
while (yield take('SWAP_START_POLL_BITY_ORDER_STATUS')) {
// starts the task in the background
const pollBityOrderStatusTask = yield fork(pollBityOrderStatus);
// wait for the user to get to point where refresh is no longer needed
yield take('SWAP_STOP_POLL_BITY_ORDER_STATUS');
// cancel the background task
// this will cause the forked loadBityRates task to jump into its finally block
yield cancel(pollBityOrderStatusTask);
}
}
function* postBityOrderCreate(action) {
const payload = action.payload;
try {
yield put(stopLoadBityRatesSwap());
const order = yield call(
postOrder,
payload.amount,
payload.destAddress,
payload.mode,
payload.pair
);
if (order.error) {
// TODO - handle better / like existing site?
yield put(
showNotification('danger', `Bity Error: ${order.msg}`, TEN_SECONDS)
);
yield put({ type: 'SWAP_ORDER_CREATE_FAILED' });
} else {
yield put(bityOrderCreateSucceededSwap(order.data));
yield put(changeStepSwap(3));
// start countdown
yield put(startOrderTimerSwap());
// start bity order status polling
yield put(startPollBityOrderStatus());
}
} catch (e) {
const message =
'Connection Error. Please check the developer console for more details and/or contact support';
yield put(showNotification('danger', message, TEN_SECONDS));
yield put({ type: 'SWAP_ORDER_CREATE_FAILED' });
}
}
export function* postBityOrderSaga(): Generator<Effect, void, any> {
yield takeEvery('SWAP_ORDER_CREATE_REQUESTED', postBityOrderCreate);
}
export function* bityTimeRemaining() {
while (yield take('SWAP_ORDER_START_TIMER')) {
let hasShownNotification = false;
while (true) {
yield call(delay, ONE_SECOND);
const swap = yield select(getSwap);
// if (swap.bityOrder.status === 'OPEN') {
const createdTimeStampMoment = moment(
swap.orderTimestampCreatedISOString
);
let validUntil = moment(createdTimeStampMoment).add(swap.validFor, 's');
let now = moment();
if (validUntil.isAfter(now)) {
let duration = moment.duration(validUntil.diff(now));
let seconds = duration.asSeconds();
yield put(orderTimeSwap(parseInt(seconds)));
// TODO (!Important) - check orderStatus here and stop polling / show notifications based on status
} else {
switch (swap.orderStatus) {
case 'OPEN':
yield put(orderTimeSwap(0));
yield put(stopPollBityOrderStatus());
yield put({ type: 'SWAP_STOP_LOAD_BITY_RATES' });
if (!hasShownNotification) {
hasShownNotification = true;
yield put(
showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity')
);
}
break;
case 'CANC':
yield put(orderTimeSwap(0));
yield put(stopPollBityOrderStatus());
yield put({ type: 'SWAP_STOP_LOAD_BITY_RATES' });
if (!hasShownNotification) {
hasShownNotification = true;
yield put(
showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity')
);
}
break;
case 'RCVE':
yield put(orderTimeSwap(0));
if (!hasShownNotification) {
hasShownNotification = true;
yield put(
showNotification('warning', BITY_TIMEOUT_MESSAGE, 'infinity')
);
}
break;
case 'FILL':
yield put(orderTimeSwap(0));
yield put(stopPollBityOrderStatus());
yield put({ type: 'SWAP_STOP_LOAD_BITY_RATES' });
break;
}
}
}
}
}

View File

@ -1,19 +1,26 @@
// @flow
import { call, put, fork, take, cancel, cancelled } from 'redux-saga/effects';
import type { Effect } from 'redux-saga/effects';
import { delay } from 'redux-saga';
import { updateBityRatesSwap } from 'actions/swap';
import { getAllRates } from 'api/bity';
import {
call,
put,
fork,
take,
cancel,
cancelled,
takeLatest
} from 'redux-saga/effects';
import type { Effect } from 'redux-saga/effects';
import { loadBityRatesSucceededSwap } from 'actions/swap';
export function* loadBityRates(_action?: any): Generator<Effect, void, any> {
try {
while (true) {
// TODO - yield put(actions.requestStart()) if we want to display swap refresh status
// TODO - BITY_RATE_REQUESTED
// network request
const data = yield call(getAllRates);
// action
yield put(updateBityRatesSwap(data));
yield put(loadBityRatesSucceededSwap(data));
// wait 5 seconds before refreshing rates
yield call(delay, 5000);
}
@ -25,11 +32,10 @@ export function* loadBityRates(_action?: any): Generator<Effect, void, any> {
}
}
export default function* bitySaga(): Generator<Effect, void, any> {
while (yield take('SWAP_LOAD_BITY_RATES')) {
export function* getBityRatesSaga(): Generator<Effect, void, any> {
while (yield take('SWAP_LOAD_BITY_RATES_REQUESTED')) {
// starts the task in the background
const loadBityRatesTask = yield fork(loadBityRates);
// wait for the user to get to point where refresh is no longer needed
yield take('SWAP_STOP_LOAD_BITY_RATES');
// cancel the background task

View File

@ -8,6 +8,7 @@ import createSagaMiddleware from 'redux-saga';
import sagas from './sagas';
import { INITIAL_STATE as configInitialState } from 'reducers/config';
import { INITIAL_STATE as customTokensInitialState } from 'reducers/customTokens';
import { INITIAL_STATE as swapInitialState } from 'reducers/swap';
import throttle from 'lodash/throttle';
import { composeWithDevTools } from 'redux-devtools-extension';
import Perf from 'react-addons-perf';
@ -37,7 +38,15 @@ const configureStore = () => {
...configInitialState,
...loadStatePropertyOrEmptyObject('config')
},
customTokens: (loadState() || {}).customTokens || customTokensInitialState
customTokens: (loadState() || {}).customTokens || customTokensInitialState,
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
swap:
loadStatePropertyOrEmptyObject('swap').step === 3
? {
...swapInitialState,
...loadStatePropertyOrEmptyObject('swap')
}
: { ...swapInitialState }
};
store = createStore(RootReducer, persistedInitialState, middleware);
@ -53,6 +62,7 @@ const configureStore = () => {
config: {
languageSelection: store.getState().config.languageSelection
},
swap: store.getState().swap,
customTokens: store.getState().customTokens
});
}),

10
package-lock.json generated
View File

@ -4252,6 +4252,11 @@
"integrity": "sha1-1M2yQw3uGjWZ8Otv5VEUbjAnJWo=",
"dev": true
},
"font-awesome": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
},
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -8224,6 +8229,11 @@
}
}
},
"moment": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz",
"integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8="
},
"mozjpeg": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-4.1.1.tgz",

View File

@ -9,11 +9,13 @@
},
"dependencies": {
"big.js": "^3.1.3",
"ethereum-blockies": "https://github.com/MyEtherWallet/blockies.git",
"ethereum-blockies": "git+https://github.com/MyEtherWallet/blockies.git",
"ethereumjs-util": "^5.1.2",
"ethereumjs-wallet": "^0.6.0",
"font-awesome": "^4.7.0",
"idna-uts46": "^1.1.0",
"lodash": "^4.17.4",
"moment": "^2.18.1",
"prop-types": "^15.5.8",
"qrcode": "^0.8.2",
"react": "^15.4.2",

View File

@ -40,10 +40,6 @@ module.exports = {
loaders: ['babel-loader'],
exclude: [/node_modules\/(?!ethereum-blockies|idna-uts46)/]
},
{
test: /\.(ico|webp|eot|otf|ttf|woff|woff2)(\?.*)?$/,
loader: 'file-loader?limit=100000'
},
{
test: /\.(gif|png|jpe?g|svg)$/i,
loaders: [