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

View File

@ -1,124 +1,139 @@
@import 'common/sass/variables'; @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 { .SwapDropdown {
position: relative; position: relative;
button { margin-left: $space-xs;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #ccc; &-button {
padding: 0.4rem 1rem; height: 3rem;
border-radius: 2px; padding-left: $space;
height: 2.5rem; padding-right: $space;
&:active,
&:hover { &-logo {
opacity: 0.8; height: 1.4rem;
} margin: -.2rem .3rem 0 -.2rem;
> li { width: auto;
margin: 0;
&:first-child {
padding-top: 4px;
}
&:last-child {
padding-bottom: 4px;
}
> a {
font-weight: 300;
&.active {
color: $link-color;
}
}
}
}
} }
.SwapDropdown-grid { &-label {
position: absolute; padding-right: .75rem;
display: none;
padding: 0;
margin-bottom: 0;
min-width: 500px;
left: 50%;
top: 50px; &:after {
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: ''; content: '';
position: absolute; position: absolute;
top: -20px; top: 50%;
left: 50%; right: .75rem;
transform: translateX(-50%); // transform: translateY(-50%);
border-right: 10px solid transparent; @include triangle(8px, $text-color, down);
border-left: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid #fff;
} }
&.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;
}
}
.inactive {
a {
color: grey;
&:hover {
background-color: #fff;
color: #163151;
cursor: not-allowed;
}
}
img {
filter: grayscale(100%);
}
}
strong {
margin-left: 5px;
}
@media screen and (max-width: 800px) {
min-width: 300px;
} }
} }
.SwapDropdown-desc { &-menu {
display: inline-block; position: absolute;
} top: 100%;
right: -$menu-offset;
z-index: $zindex-popover;
.SwapDropdown-item { &-content {
position: relative;
img {
padding-right: 1px;
}
}
.swap-option {
&-wrapper {
font-size: 1rem;
display: flex; 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;
}
&-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);
}
}
}
.SwapOption {
@include reset-button;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
padding: 0.75rem 1rem; width: 100%;
max-width: $option-width;
padding: $space-sm $space-md;
text-align: left;
background: rgba($brand-info, 0);
color: $text-color;
transition: $transition;
@media (max-width: $screen-sm) {
width: 50%;
max-width: none;
} }
&-img {
width: 1rem; &:hover {
margin-right: 8px; color: #FFF;
background: rgba($brand-info, 0.9);
}
&-top {
display: flex;
flex-direction: row;
margin-bottom: $space-xs;
}
&-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 React, { PureComponent } from 'react';
import classnames from 'classnames';
import { Option } from 'react-select';
import './SwapDropdown.scss'; import './SwapDropdown.scss';
import { DropDown } from 'components/ui';
export interface SingleCoin { export interface SingleCoin {
id: string; id: string;
@ -9,71 +10,178 @@ export interface SingleCoin {
status: string; status: string;
} }
interface Props<T> { interface Props {
options: SingleCoin[]; options: SingleCoin[];
disabledOption?: string;
value: string; value: string;
onChange(value: T): void; onChange(value: SingleCoin): void;
} }
const ValueComp: React.SFC = (props: any) => { interface State {
return ( isOpen: boolean;
<div className={`${props.className} swap-option-wrapper`}> mainOptions: SingleCoin[];
<img src={props.value.img} className="swap-option-img" alt={props.value.label + ' logo'} /> otherOptions: SingleCoin[];
<span className="swap-option-label">{props.value.label}</span> }
</div>
); const MAIN_OPTIONS = ['ETH', 'BTC'];
class SwapDropdown extends PureComponent<Props, State> {
public state: State = {
isOpen: false,
mainOptions: [],
otherOptions: []
}; };
const OptionComp: React.SFC = (props: any) => { public dropdown: HTMLDivElement | null;
const handleMouseDown = (event: React.MouseEvent<any>) => {
event.preventDefault(); public componentWillMount() {
event.stopPropagation(); this.buildOptions(this.props.options);
props.onSelect(props.option, event); 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 handleMouseEnter = (event: React.MouseEvent<any>) => {
props.onFocus(props.option, event); private handleChange = (coin: SingleCoin) => {
this.props.onChange(coin);
if (this.state.isOpen) {
this.toggleMenu();
}
}; };
const handleMouseMove = (event: React.MouseEvent<any>) => {
if (props.isFocused) { private handleBodyClick = (ev: MouseEvent) => {
if (!this.state.isOpen || !this.dropdown) {
return; return;
} }
props.onFocus(props.option, event);
}; if (
return ( ev.target !== this.dropdown &&
<div ev.target instanceof HTMLElement &&
className={`${props.className} swap-option-wrapper`} !this.dropdown.contains(ev.target)
onMouseDown={handleMouseDown} ) {
onMouseEnter={handleMouseEnter} this.toggleMenu();
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>> { private buildOptions(options: Props['options']) {
public render() { const mainOptions: SingleCoin[] = [];
const { options, value, onChange } = this.props; let otherOptions: SingleCoin[] = [];
const mappedOptions = options.map(opt => {
return { label: opt.id, value: opt.name, img: opt.image, status: opt.status }; options.forEach(opt => {
if (MAIN_OPTIONS.includes(opt.id)) {
mainOptions.push(opt);
} else {
otherOptions.push(opt);
}
}); });
return (
<DropDown // Sort non-main coins alphabetically
className="Swap-dropdown" otherOptions = otherOptions.sort(
options={mappedOptions} (opt1, opt2) => (opt1.id.toLowerCase() > opt2.id.toLowerCase() ? 1 : -1)
optionComponent={(props: any) => {
return <OptionComp {...props} />;
}}
value={value}
clearable={false}
onChange={onChange}
valueComponent={(props: any) => {
return <ValueComp {...props} />;
}}
/>
); );
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; export default SwapDropdown;

View File

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

View File

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