Shapeshift Token Box UI (#1371)

* Convert to box style, fix TRST

* Disabled option, better responsive behavior.

* Removed unnecessary logic of splitting out origin and destination option arrays.
This commit is contained in:
William O'Beirne 2018-03-23 12:25:44 -04:00 committed by Daniel Ternyak
parent bdaf40a0ce
commit 02711b390f
5 changed files with 302 additions and 212 deletions

View File

@ -193,8 +193,7 @@ class ShapeshiftService {
private mapMarketInfo(marketInfo: (IPairData & IAvailablePairData)[]) {
const tokenMap: TokenMap = {};
marketInfo.forEach(m => {
const originKind = m.pair.substring(0, 3);
const destinationKind = m.pair.substring(4, 7);
const [originKind, destinationKind] = m.pair.split('_');
if (this.isWhitelisted(originKind) && this.isWhitelisted(destinationKind)) {
const pairName = originKind + destinationKind;
const { rate, limit, min } = m;

View File

@ -1,124 +1,139 @@
@import 'common/sass/variables';
@import 'common/sass/mixins';
$menu-max-width: 540px;
$menu-padding: 10px;
$menu-triangle-size: 14px;
$menu-offset: 40px;
$option-width: ($menu-max-width - $menu-padding * 2) / 3;
$option-width-main: ($menu-max-width - $menu-padding * 2) / 2;
$option-width-small: $option-width-main;
$option-width-main-small: 100%;
.SwapDropdown {
position: relative;
button {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #ccc;
padding: 0.4rem 1rem;
border-radius: 2px;
height: 2.5rem;
&:active,
&:hover {
opacity: 0.8;
margin-left: $space-xs;
&-button {
height: 3rem;
padding-left: $space;
padding-right: $space;
&-logo {
height: 1.4rem;
margin: -.2rem .3rem 0 -.2rem;
width: auto;
}
> li {
margin: 0;
&:first-child {
padding-top: 4px;
}
&:last-child {
padding-bottom: 4px;
}
> a {
font-weight: 300;
&.active {
color: $link-color;
}
&-label {
padding-right: .75rem;
&:after {
content: '';
position: absolute;
top: 50%;
right: .75rem;
// transform: translateY(-50%);
@include triangle(8px, $text-color, down);
}
}
}
}
.SwapDropdown-grid {
position: absolute;
display: none;
padding: 0;
margin-bottom: 0;
min-width: 500px;
left: 50%;
top: 50px;
transform: translateX(-50%);
list-style: none;
font-size: 0.8rem;
text-align: left;
z-index: 500;
background: white;
box-shadow: 2px 1px 60px rgba(0, 0, 0, 0.175);
&::before {
content: '';
&-menu {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
border-right: 10px solid transparent;
border-left: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid #fff;
}
top: 100%;
right: -$menu-offset;
z-index: $zindex-popover;
&.open {
display: block;
}
li {
display: inline-block;
width: 33.3%;
margin-bottom: 0;
}
li > a {
display: block;
clear: both;
padding: 5px 20px;
color: #163151;
&:hover {
opacity: 0.8;
background-color: #163151;
color: #fff;
&-content {
display: flex;
flex-wrap: wrap;
flex-direction: row;
width: calc(100vw - 30px);
max-width: $menu-max-width;
padding: $menu-padding;
background: #FFF;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-size: 0.8rem;
}
}
.inactive {
a {
color: grey;
&:hover {
background-color: #fff;
color: #163151;
cursor: not-allowed;
}
&-triangle {
position: absolute;
top: -($menu-triangle-size / 2);
right: $menu-offset + 40px;
width: $menu-triangle-size;
height: $menu-triangle-size;
background: #FFF;
box-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1);
transform: rotate(45deg);
}
img {
filter: grayscale(100%);
}
}
strong {
margin-left: 5px;
}
@media screen and (max-width: 800px) {
min-width: 300px;
}
}
.SwapDropdown-desc {
display: inline-block;
}
.SwapOption {
@include reset-button;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: $option-width;
padding: $space-sm $space-md;
text-align: left;
background: rgba($brand-info, 0);
color: $text-color;
transition: $transition;
.SwapDropdown-item {
position: relative;
img {
padding-right: 1px;
@media (max-width: $screen-sm) {
width: 50%;
max-width: none;
}
}
.swap-option {
&-wrapper {
font-size: 1rem;
&:hover {
color: #FFF;
background: rgba($brand-info, 0.9);
}
&-top {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
flex-direction: row;
margin-bottom: $space-xs;
}
&-img {
width: 1rem;
margin-right: 8px;
&-logo {
height: 1.2rem;
width: auto;
margin-right: $space-xs;
.is-main > & {
height: 2.6rem;
margin-right: $space-md;
}
}
&-ticker {
}
&-name {
font-weight: 300;
}
&.is-main {
flex-direction: row;
justify-content: center;
max-width: $option-width-main;
padding: $space $space-md;
font-size: 1rem;
@media (max-width: $screen-sm) {
width: 50%;
max-width: none;
}
}
&.is-disabled {
filter: grayscale(1);
opacity: 0.3;
pointer-events: none;
}
}

View File

@ -1,6 +1,7 @@
import React, { PureComponent } from 'react';
import classnames from 'classnames';
import { Option } from 'react-select';
import './SwapDropdown.scss';
import { DropDown } from 'components/ui';
export interface SingleCoin {
id: string;
@ -9,71 +10,178 @@ export interface SingleCoin {
status: string;
}
interface Props<T> {
interface Props {
options: SingleCoin[];
disabledOption?: string;
value: string;
onChange(value: T): void;
onChange(value: SingleCoin): void;
}
const ValueComp: React.SFC = (props: any) => {
return (
<div className={`${props.className} swap-option-wrapper`}>
<img src={props.value.img} className="swap-option-img" alt={props.value.label + ' logo'} />
<span className="swap-option-label">{props.value.label}</span>
</div>
);
};
interface State {
isOpen: boolean;
mainOptions: SingleCoin[];
otherOptions: SingleCoin[];
}
const OptionComp: React.SFC = (props: any) => {
const handleMouseDown = (event: React.MouseEvent<any>) => {
event.preventDefault();
event.stopPropagation();
props.onSelect(props.option, event);
const MAIN_OPTIONS = ['ETH', 'BTC'];
class SwapDropdown extends PureComponent<Props, State> {
public state: State = {
isOpen: false,
mainOptions: [],
otherOptions: []
};
const handleMouseEnter = (event: React.MouseEvent<any>) => {
props.onFocus(props.option, event);
public dropdown: HTMLDivElement | null;
public componentWillMount() {
this.buildOptions(this.props.options);
document.addEventListener('click', this.handleBodyClick);
}
public componentWillUnmount() {
document.removeEventListener('click', this.handleBodyClick);
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.options !== nextProps.options) {
this.buildOptions(nextProps.options);
}
}
public render() {
const { options, value, disabledOption } = this.props;
const { isOpen, mainOptions, otherOptions } = this.state;
const selectedOption = options.find(opt => opt.name === value);
return (
<div className="SwapDropdown" ref={el => (this.dropdown = el)}>
<button className="SwapDropdown-button btn btn-default" onClick={this.toggleMenu}>
{selectedOption ? (
<React.Fragment>
<img src={selectedOption.image} className="SwapDropdown-button-logo" />
<span className="SwapDropdown-button-label">{selectedOption.id}</span>
</React.Fragment>
) : (
'Unknown'
)}
</button>
{isOpen && (
<div className="SwapDropdown-menu">
<i className="SwapDropdown-menu-triangle" />
<div className="SwapDropdown-menu-content">
{mainOptions.map(opt => (
<SwapOption
key={opt.name}
option={opt}
isMain={true}
isDisabled={opt.name === disabledOption}
onChange={this.handleChange}
/>
))}
{otherOptions.map(opt => (
<SwapOption
key={opt.name}
option={opt}
isMain={false}
isDisabled={opt.name === disabledOption}
onChange={this.handleChange}
/>
))}
</div>
</div>
)}
</div>
);
}
private toggleMenu = () => {
this.setState({ isOpen: !this.state.isOpen });
};
const handleMouseMove = (event: React.MouseEvent<any>) => {
if (props.isFocused) {
private handleChange = (coin: SingleCoin) => {
this.props.onChange(coin);
if (this.state.isOpen) {
this.toggleMenu();
}
};
private handleBodyClick = (ev: MouseEvent) => {
if (!this.state.isOpen || !this.dropdown) {
return;
}
props.onFocus(props.option, event);
};
return (
<div
className={`${props.className} swap-option-wrapper`}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
>
<img src={props.option.img} className="swap-option-img" alt={props.option.label + ' logo'} />
<span className="swap-option-label">{props.option.label}</span>
</div>
);
};
class SwapDropdown<T> extends PureComponent<Props<T>> {
public render() {
const { options, value, onChange } = this.props;
const mappedOptions = options.map(opt => {
return { label: opt.id, value: opt.name, img: opt.image, status: opt.status };
if (
ev.target !== this.dropdown &&
ev.target instanceof HTMLElement &&
!this.dropdown.contains(ev.target)
) {
this.toggleMenu();
}
};
private buildOptions(options: Props['options']) {
const mainOptions: SingleCoin[] = [];
let otherOptions: SingleCoin[] = [];
options.forEach(opt => {
if (MAIN_OPTIONS.includes(opt.id)) {
mainOptions.push(opt);
} else {
otherOptions.push(opt);
}
});
return (
<DropDown
className="Swap-dropdown"
options={mappedOptions}
optionComponent={(props: any) => {
return <OptionComp {...props} />;
}}
value={value}
clearable={false}
onChange={onChange}
valueComponent={(props: any) => {
return <ValueComp {...props} />;
}}
/>
// Sort non-main coins alphabetically
otherOptions = otherOptions.sort(
(opt1, opt2) => (opt1.id.toLowerCase() > opt2.id.toLowerCase() ? 1 : -1)
);
this.setState({ mainOptions, otherOptions });
}
}
interface SwapOptionProps {
option: SingleCoin;
isMain?: boolean;
isDisabled?: boolean;
onChange(opt: Option): void;
}
const SwapOption: React.SFC<SwapOptionProps> = ({ option, isMain, isDisabled, onChange }) => {
const handleChange = (ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
onChange({
label: option.id,
value: option.name
});
};
const classNames = classnames('SwapOption', isMain && 'is-main', isDisabled && 'is-disabled');
return (
<button className={classNames} disabled={isDisabled} onClick={handleChange}>
{isMain ? (
<React.Fragment>
<img src={option.image} className="SwapOption-logo" alt={`${option.name} logo`} />
<div className="SwapOption-info">
<div className="SwapOption-ticker">{option.id}</div>
<div className="SwapOption-name">{option.name}</div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<div className="SwapOption-top">
<img src={option.image} className="SwapOption-logo" alt={`${option.name} logo`} />
<div className="SwapOption-ticker">{option.id}</div>
</div>
<div className="SwapOption-name">{option.name}</div>
</React.Fragment>
)}
</button>
);
};
export default SwapDropdown;

View File

@ -27,10 +27,11 @@
justify-content: center;
margin: 0 -8px;
margin-bottom: 2rem;
> .input-group-wrapper {
margin: 0 8px;
width: 100%;
max-width: 400px;
max-width: 320px;
}
}
@media (max-width: $screen-sm) {

View File

@ -12,7 +12,7 @@ import translate, { translateRaw } from 'translations';
import { combineAndUpper } from 'utils/formatters';
import { SwapDropdown, Input } from 'components/ui';
import Spinner from 'components/ui/Spinner';
import { merge, reject, debounce } from 'lodash';
import { merge, debounce } from 'lodash';
import './CurrencySwap.scss';
export interface StateProps {
@ -29,11 +29,10 @@ export interface ActionProps {
}
interface State {
options: any[];
disabled: boolean;
origin: SwapOpt;
destination: SwapOpt;
originKindOptions: any[];
destinationKindOptions: any[];
originErr: string;
destinationErr: string;
timeout: boolean;
@ -50,6 +49,7 @@ interface SwapOpt extends SwapInput {
export default class CurrencySwap extends PureComponent<Props, State> {
public state: State = {
options: [],
disabled: true,
origin: {
label: 'BTC',
@ -65,8 +65,6 @@ export default class CurrencySwap extends PureComponent<Props, State> {
image: 'https://shapeshift.io/images/coins/ether.png',
amount: NaN
},
originKindOptions: [],
destinationKindOptions: [],
originErr: '',
destinationErr: '',
timeout: false
@ -110,20 +108,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
});
}, 10000);
const { origin } = this.state;
const { options } = this.props;
if (options.allIds && options.byId) {
const originKindOptions: any[] = Object.values(options.byId);
const destinationKindOptions: any[] = Object.values(
reject<any>(options.byId, o => o.id === origin.label)
);
this.setState({
originKindOptions,
destinationKindOptions
});
}
this.setState({ options: Object.values(this.props.options.byId) });
}
public componentWillUnmount() {
@ -132,24 +117,17 @@ export default class CurrencySwap extends PureComponent<Props, State> {
}
}
public componentDidUpdate(prevProps: Props, prevState: State) {
public componentWillReceiveProps(nextProps: Props) {
if (nextProps.options !== this.props.options) {
this.setState({ options: Object.values(nextProps.options.byId) });
}
}
public componentDidUpdate(_: Props, prevState: State) {
const { origin, destination } = this.state;
const { options } = this.props;
if (origin !== prevState.origin) {
this.setDisabled(origin, destination);
}
if (options.allIds !== prevProps.options.allIds && options.byId) {
const originKindOptions: any[] = Object.values(options.byId);
const destinationKindOptions: any[] = Object.values(
reject<any>(options.byId, o => o.id === origin.label)
);
this.setState({
originKindOptions,
destinationKindOptions
});
}
}
public rateMixer = () => {
@ -280,8 +258,8 @@ export default class CurrencySwap extends PureComponent<Props, State> {
};
public onChangeOriginKind = (newOption: any) => {
const { origin, destination, destinationKindOptions } = this.state;
const { options, initSwap } = this.props;
const { origin, destination } = this.state;
const { initSwap } = this.props;
const newOrigin = { ...origin, label: newOption.label, value: newOption.value, amount: 0 };
const newDest = {
@ -294,11 +272,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
this.setState({
origin: newOrigin,
destination: newDest,
destinationKindOptions: reject(
[...destinationKindOptions, options.byId[origin.label]],
o => o.id === newOption.label
)
destination: newDest
});
initSwap({ origin: newOrigin, destination: newDest });
@ -324,15 +298,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
public render() {
const { bityRates, shapeshiftRates, provider } = this.props;
const {
origin,
destination,
originKindOptions,
destinationKindOptions,
originErr,
destinationErr,
timeout
} = this.state;
const { options, origin, destination, originErr, destinationErr, timeout } = this.state;
const pairName = combineAndUpper(origin.label, destination.label);
const bityLoaded = bityRates.byId && bityRates.byId[pairName] ? true : false;
const shapeshiftLoaded = shapeshiftRates.byId && shapeshiftRates.byId[pairName] ? true : false;
@ -347,7 +313,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
<div className="flex-spacer" />
<div className="input-group-wrapper">
<div className="input-group-header">{translate('SWAP_DEPOSIT_INPUT_LABEL')}</div>
<label className="input-group input-group-inline">
<div className="input-group input-group-inline">
<Input
id="origin-swap-input"
className={`input-group-input ${
@ -362,16 +328,16 @@ export default class CurrencySwap extends PureComponent<Props, State> {
onChange={this.onChangeAmount}
/>
<SwapDropdown
options={originKindOptions}
options={options}
value={origin.value}
onChange={this.onChangeOriginKind}
/>
</label>
</div>
{originErr && <span className="CurrencySwap-error-message">{originErr}</span>}
</div>
<div className="input-group-wrapper">
<label className="input-group input-group-inline">
<div className="input-group input-group-inline">
<div className="input-group-header">{translate('SWAP_RECIEVE_INPUT_LABEL')}</div>
<Input
id="destination-swap-input"
@ -387,11 +353,12 @@ export default class CurrencySwap extends PureComponent<Props, State> {
onChange={this.onChangeAmount}
/>
<SwapDropdown
options={destinationKindOptions}
options={options}
disabledOption={origin.value}
value={destination.value}
onChange={this.onChangeDestinationKind}
/>
</label>
</div>
{destinationErr && (
<span className="CurrencySwap-error-message">{destinationErr}</span>
)}